Sign-in and consent screens
Build the login, consent, and account screens that the authorize endpoint redirects to.
When a client sends a user to /oauth2/authorize, Better Auth redirects the user through pages you host: login, optional account steps, and consent.
How the redirect works
To stop anyone from changing the authorize parameters while the user moves through your pages, each redirect carries the original parameters as a signed query. Your pages pass it back as oauth_query, and Better Auth verifies it before resuming. A query that was changed or has expired fails with an invalid_signature error. For example, a query with a different redirect_uri fails. The signature covers every authorize parameter, including custom ones, and expires after codeExpiresIn (10 minutes by default).
The oauthProviderClient plugin sends oauth_query from the current page URL on every POST request. If you call the endpoints another way, send the signed query yourself. Your own page parameters can sit in the same URL, but keep them out of oauth_query.
The authorize endpoint runs these steps in order and stops at the first one that needs the user:
- No session, a session older than
max_age, orprompt=login: redirect tologinPage.prompt=createredirects to the sign-up page instead. prompt=select_account, orselectAccount.shouldRedirectreturnstrue: redirect to the select account page.signup.shouldRedirectreturnstrueor a path: redirect to the sign-up page.postLogin.shouldRedirectreturnstrue: redirect to the post login page.prompt=consent, or the client lacksskip_consentand stored consent does not cover the requested scopes, claims, and resources: redirect toconsentPage.- Redirect to the client's
redirect_uriwith the authorization code.
With prompt=none, any step that needs the user returns an error such as login_required or consent_required to the client instead of redirecting.
Better Auth also applies these rules to the authorize request (OAuth 2.1 §4.1.1):
response_typemust becode, and PKCE accepts onlyS256(RFC 7636 §4.2).stateis optional and echoed back when sent. Clients should still send it (RFC 6749 §10.12).- Every success and error response includes
iss(RFC 9207 §2). resourcerestricts the tokens to a resource server (RFC 8707 §2). See Resources.
Login page
To send users to your sign-in page, set loginPage. When the sign-in request carries oauth_query and creates a session, the plugin continues the authorization flow. With oauthProviderClient, your page needs no OAuth code.
oauthProvider({
loginPage: "/sign-in",
})Consent page
To ask users to approve a client, set consentPage. A client with skip_consent: true skips this page. A request with prompt=consent shows it anyway.
oauthProvider({
consentPage: "/consent",
})To trust the consent query, verify it on the server before you render the page. The query holds client_id, scope, and, when requested, claims. The helper below keeps only the signed parameters, so your page's own parameters do not break the signature. It reads the provider's secret from auth.$context, so run it only on the server.
import { verifyOAuthQueryParams } from "@better-auth/oauth-provider";
import { auth } from "@/lib/auth";
export async function getVerifiedConsentQuery(request: Request) {
const params = new URL(request.url).searchParams;
const signed = new Set(["sig", "ba_param", ...params.getAll("ba_param")]);
const query = new URLSearchParams(
[...params].filter(([key]) => signed.has(key)),
).toString();
const { secret } = await auth.$context;
if (!(await verifyOAuthQueryParams(query, secret))) return null;
return new URLSearchParams(query);
}If the helper returns null, return an error response. The check rejects only queries that were changed or have expired. It does not prove that the client exists, that the user is signed in, or that the scopes are valid.
To show which client is asking, load it with the signed client_id. The call throws a 401 error without a session, and a 404 error for unknown or disabled clients.
const client = await auth.api.getOAuthClientPublic({
query: { client_id: query.get("client_id")! },
headers: request.headers,
});For any client an administrator did not create, whoever registered it chose its name, logo, and links. That includes dynamically registered and CIMD clients, and clients any signed-in user created with createOAuthClient while clientPrivileges is unset. So also show the host of the signed redirect_uri. Render logo_uri, client_uri, tos_uri, and policy_uri only when they start with https:. Then show the requested scope and claims.userinfo names, and call oauth2.consent with the user's choice.
const { data, error } = await authClient.oauth2.consent({ accept, // required, Accept or deny the request. scope, // Space-separated accepted scopes, a subset of the requested ones. Defaults to all requested scopes. claims, // Accepted OIDC claims request object. Defaults to all requested claims. oauth_query, // The signed query of the current page. The client plugin sends it for you.});acceptbooleanrequiredAccept or deny the request.
scopestringSpace-separated accepted scopes, a subset of the requested ones. Defaults to all requested scopes.
claimsstring | Record<string, unknown>Accepted OIDC claims request object. Defaults to all requested claims.
oauth_querystringThe signed query of the current page. The client plugin sends it for you.
The response is { redirect: true, url }. By default, the auth client sends the browser to url. If you call the endpoint another way, send the browser to url yourself.
With accept: false, the user returns to the client with access_denied, and any consent given earlier stays unchanged. To remove consent, see Manage consents.
Sign-up account screen
To send prompt=create requests (OpenID Connect Prompt Create) to a separate sign-up page, set signup.page. Without it, Better Auth redirects to loginPage.
oauthProvider({
signup: {
page: "/sign-up",
},
})To hold signed-in users on extra registration steps, add shouldRedirect. It receives headers, user, session, and scopes. Return true to redirect to page, a path to redirect there, or false to continue.
oauthProvider({
signup: {
page: "/sign-up",
shouldRedirect: async ({ user }) => {
return user.onboarded ? false : "/setup";
},
},
})When registration is done, call authClient.oauth2.continue({ created: true }). The auth client then sends the browser to the next step. See Continue endpoint.
Select account screen
To support prompt=select_account (OIDC Core §3.1.2.1), set selectAccount. shouldRedirect receives headers, user, session, and scopes. It returns true to redirect to page. Without selectAccount.page, that prompt returns an error. This example uses the multi-session plugin and asks whenever more than one session is signed in:
oauthProvider({
selectAccount: {
page: "/select-account",
shouldRedirect: async ({ headers }) => {
const sessions = await auth.api.listDeviceSessions({ headers });
return sessions.length > 1;
},
},
})After the user picks an account, resume the flow:
await authClient.multiSession.setActive({ sessionToken });
await authClient.oauth2.continue({ selected: true });Post-login screen
To add a step after sign-in and before consent, such as picking an organization, set postLogin with a page and these callbacks:
shouldRedirectreceivesheaders,user,session,scopes, andisContinuation. It returnstrueto redirect topage.consentReferenceIdreceivesuser,session, andscopes. It returns the user's choice, such as an organization ID. Better Auth stores consent separately for each reference. WhenconsentReferenceIdreturnsundefinedor an empty string, the consent given without a reference applies.
oauthProvider({
postLogin: {
page: "/select-organization",
shouldRedirect: async ({ session, headers, isContinuation }) => {
const orgs = await auth.api.listOrganizations({ headers });
if (!orgs.some((org) => org.id === session.activeOrganizationId)) {
return true;
}
return !isContinuation && orgs.length > 1;
},
consentReferenceId: ({ session }) =>
session.activeOrganizationId as string | undefined,
},
})When the user returns from your page, shouldRedirect runs again with isContinuation: true. Ask users with several organizations on a new authorization. On continuation, return false once the active organization is one the user belongs to. isContinuation does not prove a selection was made, so always check the session.
await authClient.organization.setActive({ organizationId });
await authClient.oauth2.continue({ postLogin: true });If some scopes need a reference, throw an APIError from consentReferenceId when none is set. Better Auth does not check this for you. For how references reach token claims, see Organizations.
Continue endpoint
To resume authorization from an account page, call this endpoint with the flag that matches the page. Better Auth runs the authorize steps again and responds with { redirect: true, url }. The url can be another account page, the consent page, or the client's redirect_uri. By default, the auth client sends the browser there.
Without a flag or the signed query, the call returns a 400 invalid_request error. Call it from the page that Better Auth redirected to, where the URL still carries the signed query. Without oauthProviderClient, send that query as oauth_query.
const { data, error } = await authClient.oauth2.continue({ selected, // Confirms an account was selected on the select account page. created, // Confirms an account was registered on the sign-up page. postLogin, // Resumes after the post login page. `postLogin.shouldRedirect` runs again with `isContinuation: true`. oauth_query, // The signed query of the current page. The client plugin sends it for you.});selectedbooleanConfirms an account was selected on the select account page.
createdbooleanConfirms an account was registered on the sign-up page.
postLoginbooleanResumes after the post login page. postLogin.shouldRedirect runs again with isContinuation: true.
oauth_querystringThe signed query of the current page. The client plugin sends it for you.
Manage consents
To let users list and change their consents, use these endpoints. Each requires a session and only returns or changes the signed-in user's consents.
| Server method | Path | Method | Input |
|---|---|---|---|
getOAuthConsent | /oauth2/get-consent | GET | query id |
getOAuthConsents | /oauth2/get-consents | GET | none |
updateOAuthConsent | /oauth2/update-consent | POST | body { id, update: { scopes } } |
deleteOAuthConsent | /oauth2/delete-consent | POST | body { id } |
Updated scopes must be allowed for the client. Deleting a consent makes the next authorization ask again.
const { error } = await authClient.oauth2.updateConsent({
id: consentId,
update: { scopes: ["openid", "profile"] },
});
if (error) {
// A scope the client is not allowed to use returns a 400 invalid_request error.
}