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:

  1. No session, a session older than max_age, or prompt=login: redirect to loginPage. prompt=create redirects to the sign-up page instead.
  2. prompt=select_account, or selectAccount.shouldRedirect returns true: redirect to the select account page.
  3. signup.shouldRedirect returns true or a path: redirect to the sign-up page.
  4. postLogin.shouldRedirect returns true: redirect to the post login page.
  5. prompt=consent, or the client lacks skip_consent and stored consent does not cover the requested scopes, claims, and resources: redirect to consentPage.
  6. Redirect to the client's redirect_uri with 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):

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.

auth.ts
oauthProvider({
  loginPage: "/sign-in", 
})

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.

auth.ts
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.

consent-query.ts
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.

consent-page.ts
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.

POST/oauth2/consent
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.});
Parameters
acceptbooleanrequired

Accept or deny the request.

scopestring

Space-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_querystring

The 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.

auth.ts
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.

auth.ts
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:

auth.ts
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:

select-account.ts
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:

  • shouldRedirect receives headers, user, session, scopes, and isContinuation. It returns true to redirect to page.
  • consentReferenceId receives user, session, and scopes. It returns the user's choice, such as an organization ID. Better Auth stores consent separately for each reference. When consentReferenceId returns undefined or an empty string, the consent given without a reference applies.
auth.ts
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.

select-organization.ts
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.

POST/oauth2/continue
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.});
Parameters
selectedboolean

Confirms an account was selected on the select account page.

createdboolean

Confirms an account was registered on the sign-up page.

postLoginboolean

Resumes after the post login page. postLogin.shouldRedirect runs again with isContinuation: true.

oauth_querystring

The 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 methodPathMethodInput
getOAuthConsent/oauth2/get-consentGETquery id
getOAuthConsents/oauth2/get-consentsGETnone
updateOAuthConsent/oauth2/update-consentPOSTbody { id, update: { scopes } }
deleteOAuthConsent/oauth2/delete-consentPOSTbody { id }

Updated scopes must be allowed for the client. Deleting a consent makes the next authorization ask again.

consents.ts
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.
}