You are currently viewing documentation for v1.8 (Beta)
OAuth 2.1 Provider
A Better Auth plugin that enables your auth server to serve as an OAuth 2.1 provider.
The OAuth 2.1 Provider plugin turns your Better Auth server into an OAuth authorization server. Applications can request user access through the authorization code flow, while services can use client credentials. Add the openid scope when clients also need OpenID Connect (OIDC) identity claims.
The default configuration chooses the safer protocol behavior, including PKCE for public clients and exact redirect URI matching. Start with Installation, then enable only the grants and registration paths your clients use.
Key features
- OAuth security profile: follows OAuth 2.1 practices and includes the RFC 9207
issparameter to prevent authorization-server mix-up attacks. - OpenID Connect: issues ID tokens, serves UserInfo, and supports RP-initiated logout when clients request
openid. - Client registration: supports administrator-managed clients, first-party trusted clients, and optional Dynamic Client Registration.
- Public and confidential clients: derives authentication from
token_endpoint_auth_method; use"none"for clients that cannot keep a secret. - Resource-bound access: issues tokens for protected resources, supports introspection and revocation, and exposes signing keys through the JWT plugin's
/jwksendpoint. - Authorization prompts: supports consent and account-selection prompts.
- MCP composition: use the MCP plugin when the protected resource is an MCP server.
Supported grants
authorization_code: exchanges a user authorization code with S256 PKCE.refresh_token: renews access through theoffline_accessscope.client_credentials: issues machine-to-machine access tokens.device_code: adds the optional Device Authorization flow for CLIs and limited-input clients.
client_credentials is fail closed. A client's user-delegated scope metadata never authorizes machine access. Administrators must assign a non-empty client_credentials_scopes value through the administrative create or update endpoint, and clientPrivileges must explicitly approve the configure-client-credentials-scopes action. The assigned value is both the maximum requestable scope set and the default when the token request omits scope. The one other way to assign it is protected dynamic registration, where your validateInitialAccessToken callback can return it for the token it accepts. Otherwise DCR, CIMD, and user-managed registration can declare the grant but cannot assign this server-owned scope ceiling.
Installation
Mount the Plugin
Add the OAuth Provider plugin to your auth config. See Configuration Section on how to configure the plugin.
import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins";
import { oauthProvider } from "@better-auth/oauth-provider";
const auth = betterAuth({
disabledPaths: [
"/token",
],
plugins: [
jwt(),
oauthProvider({
loginPage: "/sign-in",
consentPage: "/consent",
// ...other options
})
],
});Migrate the Database
Run the migration or generate the schema to add the necessary fields and tables to the database.
npx auth migrateSee the Schema section to add the fields manually.
Confirm /.well-known endpoints
Better Auth serves the OAuth Authorization Server metadata and OpenID Connect discovery metadata from the auth handler automatically. If your framework only forwards requests under a catch-all auth route, make sure the issuer metadata URLs reach auth.handler.
- OAuth Authorization Server metadata is available at both
{issuer}/.well-known/oauth-authorization-serverand/.well-known/oauth-authorization-server/[issuer-path]. - OpenID Connect discovery metadata is available at
{issuer}/.well-known/openid-configurationwhen you use theopenidscope. - If you are using the resource server (for example, for MCP), add the OAuth Protected Resource metadata endpoint to the API that receives access tokens.
Create your first OAuth client
Create a confidential client:
const client = await auth.api.createOAuthClient({
headers,
body: {
redirect_uris: [redirectUri],
}
});
console.log(client); // If you wish, you may add the `client_id` to `cachedTrustedClients`To create a public client without a client secret, set token_endpoint_auth_method: "none".
Client Plugins
Two client plugins cover different roles. Add the OAuth client when your app starts authorization flows, and add the resource client when your API verifies access tokens.
OAuth Client
The OAuth client connects a web or native application to the authorization server.
import { createAuthClient } from "better-auth/client";
import { oauthProviderClient } from "@better-auth/oauth-provider/client"
export const authClient = createAuthClient({
plugins: [
oauthProviderClient(),
],
});Resource Client
The resource client runs in the API that receives access tokens. It verifies those tokens and serves protected-resource metadata.
import { auth } from "@/lib/auth";
import { createAuthClient } from "better-auth/client";
import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client"
export const serverClient = createAuthClient({
plugins: [
oauthProviderResourceClient(auth) // auth optional
],
});Usage
The plugin operates as an OAuth 2.1 server with OIDC compatible endpoints and JWT verifiable access tokens. The following provides more detailed information about each endpoint.
OAuth Clients
OAuth client authentication capability and application topology are separate:
- Public Clients: Cannot keep a client secret and use
token_endpoint_auth_method: "none". - Confidential Clients: Authenticate at the token endpoint with a registered method such as
client_secret_basic,client_secret_post, orprivate_key_jwt. - Application Type:
application_typeis eitherwebornativeand controls redirect URI validation only. It does not determine whether the client is public or confidential.
Get Client
To obtain client information owned by a specific user or organization use the following endpoint:
const { data, error } = await authClient.oauth2.getClient({ query: { client_id, // required, The OAuth client's client_id },});client_idstringrequiredThe OAuth client's client_id
Get Public Client
To obtain public client fields to display on login flow pages such as consent, use the following endpoint. Note: the user must be signed in to use this endpoint.:
const { data, error } = await authClient.oauth2.publicClient({ query: { client_id, // required, The OAuth client's client_id },});client_idstringrequiredThe OAuth client's client_id
Get Public Client Prelogin
To obtain a public client prior to login, you must first enable the endpoint in your configuration:
oauthProvider({
allowPublicClientPrelogin: true,
})Then, the following endpoint will obtain public client information.
const { data, error } = await authClient.oauth2.publicClientPrelogin({ client_id, // required, The OAuth client's client_id oauth_query, // required, Valid oauth query parameters (Sent automatically when using the provided client)});client_idstringrequiredThe OAuth client's client_id
oauth_querystringrequiredValid oauth query parameters (Sent automatically when using the provided client)
List Clients
To obtain a list of clients owned by a specific user or organization, use the following endpoint:
const { data, error } = await authClient.oauth2.getClients();Create Client
To create an oauth client tied to a specific user or organization, use the /oauth2/create-client endpoint (eg. createOAuthClient). The parameters are equivalent to the registration endpoint described by RFC7591.
The following fields on the database are considered restricted and should only be editable by admin users.
client_secret_expires_at: The expiration time for a secret of a confidential clientskip_consent: Allows the ability to skip user consent flow. Useful for trusted clients.enable_end_session: Allows a user to logout of a session from the client via theirid_tokenat the/oauth2/end-sessionendpoint. Used in OIDC-setups and specified trusted clients.metadata: Additional private metadata to attach to the client.
In some cases, you may wish to create logic to create oauth clients with restricted fields through custom APIs, company admin portals, or server initialization, you may use the following server-only endpoint:
import { auth } from "@/lib/auth"
await auth.api.adminCreateOAuthClient({
headers,
body: {
redirect_uris: [redirectUri],
client_secret_expires_at: 0,
skip_consent: true,
enable_end_session: true,
}
});Update Client
To update an oauth client tied to a specific user or organization, use the /oauth2/update-client endpoint (eg. updateOAuthClient). The parameters are equivalent to the registration endpoint described by RFC7591.
const { data, error } = await authClient.oauth2.updateClient({ client_id, // required, The OAuth client's client_id update: { // required, The client fields to update, as described in the create section. Omitted fields keep their current values. backchannel_logout_uri, // Back-channel logout URI. `null` removes it and `backchannel_logout_session_required`. frontchannel_logout_uri, // Front-channel logout URI. `null` removes it and `frontchannel_logout_session_required`. },});client_idstringrequiredThe OAuth client's client_id
updateObjectrequiredThe client fields to update, as described in the create section. Omitted fields keep their current values.
backchannel_logout_uristring | nullBack-channel logout URI. null removes it and backchannel_logout_session_required.
frontchannel_logout_uristring | nullFront-channel logout URI. null removes it and frontchannel_logout_session_required.
Omitted fields keep their current values. To remove a logout URI, send null for backchannel_logout_uri or frontchannel_logout_uri; the matching *_session_required flag is cleared too, and the client stops receiving those logout notifications.
Restrictions on this endpoint:
- You cannot change
token_endpoint_auth_methodafter creation. The method selected at creation determines whether the client has credential capability. - You cannot update the client secret. To rotate the
client_secretuse the rotate client secret endpoint.
In some cases, you may wish to create logic to update oauth clients with restricted fields through custom APIs, company admin portals, or server initialization, you may use the following server-only endpoint. The fields are described in the create section.:
import { auth } from "@/lib/auth"
await auth.api.adminUpdateOAuthClient({
headers,
body: {
redirect_uris: [redirectUri],
client_secret_expires_at: 0,
skip_consent: true,
enable_end_session: true,
}
});To pause token issuance without deleting a client, set disabled through the server-only update API:
await auth.api.adminUpdateOAuthClient({
headers,
body: { client_id, update: { disabled: true } },
});Set disabled: false to resume issuance. A disabled client cannot obtain new tokens, and introspection reports its existing tokens as inactive. A resource server that verifies an existing JWT locally must still check current client or application authorization if it needs immediate disconnection; the JWT signature remains valid until expiry. The session-bound updateOAuthClient endpoint cannot change disabled.
Rotate Client Secret
The current implementation rotates the client secret immediately and the previous secret is invalidated immediately.
To rotate a client secret, you must use the following endpoint:
const { data, error } = await authClient.oauth2.client.rotateSecret({ client_id, // required, The OAuth client's client_id});client_idstringrequiredThe OAuth client's client_id
Delete Client
To delete a user or organization's client, use the following endpoint:
const { data, error } = await authClient.oauth2.deleteClient({ client_id, // required, The OAuth client's client_id});client_idstringrequiredThe OAuth client's client_id
OAuth Consent
Consent is required on all non-trusted clients, specifically those without skip_consent. The following endpoints allow users or reference_id manage their given consents.
Get Consent
To obtain details of a specific consent, use the following endpoint:
const { data, error } = await authClient.oauth2.getConsent({ query: { id, // required, The consent id },});idstringrequiredThe consent id
List Consent
To obtain a list of user consents, use the following endpoint:
const { data, error } = await authClient.oauth2.getConsents();Update Consent
To update a specific consent, use the following endpoint:
const { data, error } = await authClient.oauth2.updateConsent({ id, // required, The consent id update, // required, The values to update});idstringrequiredThe consent id
updateOAuthConsentrequiredThe values to update
Delete Consent
Revokes a user's consent for a specific client.
const { data, error } = await authClient.oauth2.deleteConsent({ id, // required, The consent id});idstringrequiredThe consent id
Dynamic Registration Endpoint
This endpoint supports RFC7591 compliant client registration.
Once installed, you can utilize the OAuth Provider to manage authentication flows within your application.
After a confidential client is created, you will receive a client_id and client_secret that you can display to the user. The client_secret can only be provided once, ensure the user saves it. Public clients receive only a client_id.
Setup
To enable client registration set allowDynamicClientRegistration: true in your BetterAuth config.
oauthProvider({
allowDynamicClientRegistration: true,
// ... other options
})To enable open client registration without a Better Auth session, additionally set allowUnauthenticatedClientRegistration: true in your auth config. Public clients are registered with token_endpoint_auth_method: "none". Confidential clients receive a one-time client_secret in the registration response.
For MCP public-client identity, use Client ID Metadata Documents. The MCP 2026-07-28 spec deprecates Dynamic Client Registration in favor of CIMD; DCR remains supported for backwards compatibility.
oauthProvider({
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
// ... other options
})Basic Example
To register a new OIDC client, use the oauth2.register method.
import { authClient } from "@/lib/auth-client"
const client = await authClient.oauth2.register({
client_name: "My Client",
redirect_uris: ["https://client.example.com/callback"],
});For all endpoint parameters, see RFC 7591 Registration.
Dynamic registration keeps only the requested grant_types this server supports, and the registration response lists the grants that were registered (RFC 7591 §3.2.1). For example, Claude's MCP connector requests urn:ietf:params:oauth:grant-type:jwt-bearer alongside authorization_code and refresh_token, and registers with the last two. When authorization_code is dropped, so is the code response type. A registration that shares no grant with the server is rejected with invalid_client_metadata. Clients created with createOAuthClient or adminCreateOAuthClient are not narrowed: an unsupported grant is rejected.
application_type (OIDC Registration §2) defaults to web when omitted. A web client requires https redirect URIs on non-loopback hosts. A native client may use any of these redirect URI forms:
- A claimed
httpsURI on a non-loopback host. - An
httploopback URI on exactlylocalhost,127.0.0.1, or[::1], with any port. - An authority-free private-use URI with a reverse-domain scheme, such as
com.example.app:/callback. This is the form RFC 8252 §7.1 recommends. - A custom-scheme URI with a host and a path, such as
cursor://anysphere.cursor-mcp/oauth/callback. The scheme does not need to contain a period. A URI without a path, such ascursor://anysphere.cursor-mcp, is rejected.
Loopback host matching uses the raw URI authority, so alternative numeric IPv4 spellings such as 127.1 are rejected before URL normalization. Better Auth also rejects credentials, fragments, malformed URIs, loopback https, and routable-host http redirects. Native clients cannot use the reserved schemes ws:, wss:, file:, ftp:, mailto:, javascript:, data:, vbscript:, blob:, about:, or view-source:. Any other scheme is accepted in the host-and-path form; an authority-free URI such as myapp:/callback still needs a reverse-domain scheme.
Application type is independent of client authentication. For example, a native client may use client_secret_post, while a web client may use token_endpoint_auth_method: "none". Authentication capability is derived only from token_endpoint_auth_method; the removed type and public metadata fields are not accepted or returned.
The MCP 2026-07-28 spec requires MCP clients to send an appropriate application_type. A Client ID Metadata Document may omit it; Better Auth stores that omission as null and validates redirects against the safe union of web and native forms.
Some native MCP clients omit application_type during dynamic registration. Cursor, for example, registers cursor://anysphere.cursor-mcp/oauth/callback, an https callback, and http://localhost:8787/callback without it, and older Claude Code releases register a single http://127.0.0.1:<port>/callback, so the web default rejects both registrations. Current Claude Code releases send application_type: "native" and register without this option. clientRegistrationDefaultApplicationType sets the type that a dynamic registration gets when it omits the field:
| Value | Omitted application_type becomes |
|---|---|
"web" (default) | web, as OIDC Registration §2 specifies |
"native" | native |
"infer" | native when any redirect URI uses a non-http(s) scheme or http on localhost, 127.0.0.1, or [::1]; otherwise web |
Every redirect URI is then validated against the rules for the resulting type. Under "infer", a client that lists a custom scheme or an http loopback URI becomes native, so its other http URIs must still be loopback. A client that lists only https URIs stays web. An application_type the client sends is never overridden. The option applies only to dynamic registration; managed clients and Client ID Metadata Documents are unaffected.
oauthProvider({
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
clientRegistrationDefaultApplicationType: "infer",
// ... other options
})RFC 8252 §8.4 says authorization servers SHOULD reject private-use schemes that do not contain a period. Better Auth accepts schemes such as cursor: for compatibility with native MCP clients. Any app on a device can register a private-use scheme, so another app may receive the authorization code. PKCE makes an intercepted code unusable without the code verifier. Public clients (token_endpoint_auth_method: "none") always require PKCE. Confidential clients can opt out through require_pkce: false or clientRegistrationRequirePKCE: false; keep PKCE on for any client that uses a private-use redirect. See PKCE Configuration.
Note the following parameters are not yet supported:
sector_identifier_uri
Authorize Endpoint
An OAuth 2.1 authorization endpoint. Since many of the details are not yet fully described, parts are adapted from the legacy OAuth 2.0 Authorization Endpoint Section but always implements the differences from OAuth 2.0.
The Authorization Endpoint is the entry point for initiating an OAuth 2.1 authorization flow.
Important notes:
- In OAuth 2.1, only
response_type: "code"is supported. code_challenge_method: "plain"will not be supported since this is a security vulnerability.- All authorization responses (success and error) include the
issparameter for issuer validation (RFC 9207). - Use the
resourceindicators to restrict tokens to a resource (RFC 8707).
State
Clients should send a state value to mitigate cross-site request forgery (CSRF) attacks. This works by ensuring your client only responds to requests that your client initially requested.
Generate a state value from your client and store on your client such as in a secure, HTTP-only cookie or database.
The authorization server accepts requests without state for compatibility with OAuth and OpenID Connect, and echoes state back when it is provided. Better Auth's client helpers generate and validate state for you.
Code Challenge
Code challenges helps protect the authorization code returned from the authorization endpoint.
To do so, a code challenge is derived from a code verifier and sent in a Proof Key for Code Exchange (PKCE) to the Authorization Server.
Now at your redirect_uri (ie callback), check to see if the returned state matches the initial state, use the authorization_code grant and original code verifier at the Token Endpoint to obtain the tokens.
Token Endpoint
By default, the token endpoint supports providing tokens for the following grants:
- "authorization_code"
- "client_credentials"
- "refresh_token"
Client Authentication Methods
The token endpoint supports the following client authentication methods:
client_secret_basic— Client credentials sent via HTTP Basic Auth header (default)client_secret_post— Client credentials sent in the request bodyprivate_key_jwt— Client authenticates with a signed JWT assertion (RFC 7523)none— Public client (PKCE required)
Important Notes:
- With the JWT plugin enabled (default), sending
resourceresults in a JWT access token with the selected resource in theaudclaim. - With
disableJwtPlugin: true, access tokens remain opaque. Requested resources are still bound to the token and surfaced through/oauth2/introspectandcustomAccessTokenClaims(viaresources).
Token endpoint errors follow the OAuth taxonomy: missing required request fields and parameters sent more than once return invalid_request (only resource and audience may repeat), a malformed resource returns invalid_target, failed client authentication returns invalid_client, and invalid or mismatched grants return invalid_grant. A confidential client must use its registered token_endpoint_auth_method. Failed client_secret_post authentication returns 400; failed client_secret_basic authentication returns 401 with a Basic WWW-Authenticate challenge. For the built-in authorization_code, refresh_token, client_credentials, and device code grants, the client is authenticated before the grant is looked up, so an unknown client_id returns invalid_client rather than invalid_grant. An extension grant handler (see Provider extensions) gets the same result by calling provider.authenticateClient() before it looks up its grant.
DPoP sender-constrained tokens
Better Auth supports Demonstrating Proof of Possession (DPoP) as defined by RFC 9449. A client can ask for DPoP-bound tokens by registering with dpop_bound_access_tokens: true, by sending dpop_jkt on the authorization request, or by requesting a resource configured with dpopBoundAccessTokensRequired: true.
When a token is DPoP-bound:
- The token endpoint requires a valid
DPoPproof JWT. - The proof's
htumust be the endpoint's public URL: the origin and base path ofbaseURL, followed by the path the client requested, such ashttps://auth.example.com/api/auth/oauth2/token. SetbaseURLto the public URL clients call, not an internal one, because the expectedhtuignores the request host, which a proxy or dev server can rewrite. - The token response returns
token_type: "DPoP". - JWT access tokens include
cnf.jkt; opaque access tokens and refresh tokens persist the same JWK thumbprint. - Refresh token rotation requires a DPoP proof from the same key.
- Resource requests must use
Authorization: DPoP <access_token>and aDPoPproof containing the access-token hash (ath).
oauthProvider({
dpop: {
proofMaxAgeSeconds: 300,
signingAlgorithms: ["ES256", "EdDSA"],
},
resources: [
{
identifier: "https://api.example.com",
dpopBoundAccessTokensRequired: true,
},
],
})The authorization-server metadata advertises dpop_signing_alg_values_supported. Resource metadata advertises the same proof algorithms and, when required, dpop_bound_access_tokens_required.
Private Key JWT Authentication
With private_key_jwt, clients authenticate by signing a JWT with their private key instead of using a shared secret. The server verifies the signature using the client's registered public key (JWKS).
To register a private_key_jwt client, provide the client's public keys via jwks or jwks_uri:
const response = await auth.api.adminCreateOAuthClient({
headers,
body: {
redirect_uris: ["https://app.example.com/callback"],
token_endpoint_auth_method: "private_key_jwt",
jwks: {
keys: [
{
kty: "RSA",
kid: "my-key-1",
alg: "RS256",
use: "sig",
n: "...",
e: "...",
},
],
},
},
});jwks and jwks_uri are general OIDC client key metadata and are mutually exclusive. Inline jwks must be an RFC 7517 JWK Set object with a non-empty keys array containing only public asymmetric signing keys; a bare key array is rejected. EC keys must use P-256, P-384, or P-521; OKP keys must use Ed25519. A key may omit alg. When present, alg must be a supported private_key_jwt algorithm that matches the key type and curve. When using jwks_uri, it must be an HTTPS URL pointing to a public (non-private) host and must return the same JWK Set object shape.
When exchanging tokens, the client sends a client_assertion JWT instead of a client_secret:
POST /api/auth/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE
&redirect_uri=https://app.example.com/callback
&client_id=CLIENT_ID
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJhbGciOiJSUzI1NiIs...The assertion JWT must contain:
| Claim | Requirement |
|---|---|
iss | Must be the client_id |
sub | Must be the client_id |
aud | Must contain either the URL of the endpoint receiving the assertion or the OpenID Provider issuer. Use a string or an array containing at least one accepted value |
exp | Required, must not exceed assertionMaxLifetime from now |
jti | Required, must be unique (single-use) |
iat | Optional, but if present must not be older than assertionMaxLifetime |
Supported signing algorithms: RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512, EdDSA.
Rotating client keys
A client with inline jwks can replace its own keys without a user session. This is how a service rotates its signing key on a schedule. Enable it on the provider:
oauthProvider({
allowClientKeyRotation: true,
})The client generates a new key pair, then calls the endpoint with a client assertion signed by one of its current keys:
POST /api/auth/oauth2/client/rotate-keys
Content-Type: application/json
{
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": "eyJhbGciOiJFUzI1NiIs...",
"jwks": { "keys": [{ "kty": "EC", "crv": "P-256", "kid": "key-2", "x": "...", "y": "..." }] }
}The assertion follows the claims in the table above, with one difference: aud must include the URL of the rotation endpoint. The token endpoint and the issuer do not count as audiences here, so an assertion minted only for them cannot be redirected to take over a client's keys.
jwks is the client's complete new key set. A key left out of it stops verifying at once, so leaving out a leaked key retires it with that request. The response returns client_id and the stored jwks.
To replace a key without breaking instances that still sign with the old one, roll it in two steps, the way a client that publishes a jwks_uri does:
- Send both the old and the new key, signed by the old key.
- Once every instance signs with the new key, send only the new key, signed by the new key.
If the keys change while a rotation is in flight, the losing request fails with 409, or with 401 if its signing key was already removed. Sign a fresh assertion with a current key and retry.
Each rotation assertion must be single-use, so the provider refuses to start with allowClientKeyRotation on the memory adapter or with advanced.database.generateId: "serial". Only clients that register inline jwks can use this endpoint. A client that publishes a jwks_uri rotates its keys at that URI, and clients listed in cachedTrustedClients cannot be changed through the client management endpoints. client_secret_* clients use /oauth2/client/rotate-secret instead.
Authorization code grant
The authorization code grant enables clients to obtain access user access tokens and optionally refresh tokens (with the "offline_access" scope).
Client credentials grant
The client credentials grant enables clients to obtain machines to obtain access tokens.
Refresh token grant
The refresh token grant enables clients to update their access token without needing the user to login again.
This implementation currently issues a new refresh token for every refresh request.
Set refreshTokenReuseInterval to allow a rotated refresh token to be reused for a short interval and receive the same token response. This lets clients recover from duplicate refresh requests, lost responses, or retries without minting another token pair.
oauthProvider({
refreshTokenReuseInterval: 30, // seconds
})The default is 0, which keeps strict replay detection. During the interval, Better Auth replays the cached response only when the reused refresh token is from the same client and the request resolves to the same effective scopes, requested resources, and sender constraint (for example the same DPoP key). A mismatch during the interval returns invalid_grant without invalidating the whole family; once the interval expires, using the old refresh token is treated as replay and invalidates the refresh-token family.
The cached response is stored encrypted on the consumed refresh-token row and includes the replacement refresh token. expires_in is recalculated from the cached expires_at each time the response is replayed.
Device code grant
The device authorization grant (RFC 8628) lets limited-input clients (CLIs, smart TVs, IoT) obtain an OAuth access token. Add oauthDeviceAuthorization() alongside oauthProvider() to register the urn:ietf:params:oauth:grant-type:device_code token grant and advertise device_authorization_endpoint. The integration adds the OAuth client binding, RFC 8707 resource, consent reference, authentication time, and authentication context (acr, amr) fields; standalone Device Authorization installations remain unchanged. The device requests codes at /device/code, the user approves them through /oauth2/authorize?user_code=… and the same sign-in, account selection, post-login, and consent pages as an authorization request, and the client polls /oauth2/token for a first-class OAuth token. See Authorize a CLI to call an API.
At /device/code, confidential clients authenticate with their registered method. A client using client_secret_basic may send an Authorization: Basic ... header and omit the body client_id; a client using client_secret_post sends client_id and client_secret; and a public client using none sends client_id. Empty client_id, scope, user_id, and authentication parameters are treated as omitted regardless of order. Repeated non-empty client identification, base request, or authentication parameters and multiple authentication methods return invalid_request, while repeated resource parameters remain supported.
An unknown OAuth client ID is not silently handled as a standalone request. Standalone fallback is available only when oauthDeviceAuthorization({ validateClient }) explicitly accepts that ID. Malformed resource input returns invalid_target only when resource is the failing extension field; if a base request field is also invalid, the response returns invalid_request.
Consent Endpoint
Accept or deny user consent for a set of scopes. Note that when denying scopes, the consent cancels and pre-existing consent remains. To remove consent, delete that user's "oauthConsent" for that client.
const { data, error } = await authClient.oauth2.consent({ accept, // required, Accept or deny user consent for a set of scopes scope, // Space-separated list of accepted scopes. If not provided, the originally requested scopes are accepted. claims, // Accepted OIDC claims request object. If not provided, the originally requested claims are accepted. reference_id, // The reference the user chose, such as an organization ID. `postLogin.consentReferenceId` receives it as `consent.referenceId` and decides whether to accept it; without that callback, accepting with it fails with `invalid_request`.});acceptbooleanrequiredAccept or deny user consent for a set of scopes
scopestringSpace-separated list of accepted scopes. If not provided, the originally requested scopes are accepted.
claimsstring | Record<string, unknown>Accepted OIDC claims request object. If not provided, the originally requested claims are accepted.
reference_idstringThe reference the user chose, such as an organization ID. postLogin.consentReferenceId receives it as consent.referenceId and decides whether to accept it; without that callback, accepting with it fails with invalid_request.
Continue Endpoint
Sign up registration pages must be configured to perform account registration steps. Account selection must be configured to perform account selection. Post login must be configured to perform post login selection.
const { data, error } = await authClient.oauth2.continue({ selected, // Confirms an account was selected. created, // Confirms an account was registered postLogin, // Resumes after the post login page. `postLogin.shouldRedirect` runs again with `isContinuation: true`.});selectedbooleanConfirms an account was selected.
createdbooleanConfirms an account was registered
postLoginbooleanResumes after the post login page. postLogin.shouldRedirect runs again with isContinuation: true.
Introspect Endpoint
RFC7662-compliant Introspection. A request that sends a parameter more than once returns 400 invalid_request; an empty value counts as omitted.
This endpoint provides details of the provided token. If the token is additionally tied to a session, the endpoint will ensure the session is active. token_type_hint only sets which token type is checked first: a token sent with the wrong hint is still found.
Use the resources field in customAccessTokenClaims to add claims based on the protected resource.
Who can introspect a token
The caller must authenticate as a registered client (RFC 7662 §2.1). It can then introspect a token in two cases:
- it issued the token, or
- it is a resource server linked to one of the token's resources.
The second case is the common one: a frontend client gets the token, and your API validates it. To set this up, register the API as a resource and link the client to it. Dynamic registration and server-only adminCreateOAuthClient can link resources during registration; use adminLinkClientResource for an existing client.
Any other authenticated client gets { active: false }, the same response as an unknown or expired token, so introspection can't be used to fish for valid tokens. Refresh tokens are stricter: only the client that requested one can introspect it.
Which claims come back
Opaque and JWT access tokens return the same claims for the same grant: extension claims.accessToken contributors, your customAccessTokenClaims, and any per-resource customClaims. The server owns the reserved claim names (iss, sub, aud, scope, auth_time, and similar). If a callback returns one of those, it is dropped rather than allowed to overwrite the server's value.
The two formats differ in one way. An opaque token is recomputed on each call, so introspection shows its current state: change a resource's claims, and the next introspection reflects the change. A JWT carries what was signed at issuance and never changes. Request a JWT (by passing a resource) when you want claims frozen at issuance; use an opaque token when you want the current state.
Revoke Endpoint
RFC7009-compliant Revocation. A request that sends a parameter more than once returns 400 invalid_request; an empty value counts as omitted.
An unknown, expired, or already revoked token returns 200 instead of an error (RFC7009 §2.2). token_type_hint only sets which token type is checked first: a token sent with the wrong hint is still revoked.
A client can only revoke tokens issued to it (RFC7009 §2.1). A confidential client that sends another client's token gets 400 invalid_request. A public client gets 200 and the token stays valid, so a public client can't use the endpoint to test whether a token exists.
What the endpoint does depends on the token type:
- opaque
access_token: deleted from the database immediately. Arefresh_tokenfrom the same grant stays valid. refresh_token: marks therefresh_tokenrevoked and deletes every opaqueaccess_tokenit minted, so it can no longer issue tokens. Revoking arefresh_tokenthat was already revoked or rotated is treated as replay: it still returns200, and it deletes the client'srefresh_tokens for that user, along with the opaqueaccess_tokens minted from them. Anaccess_tokenissued without arefresh_token, and any JWTaccess_token, stays valid until it expires.- JWT
access_token: cannot be revoked server-side. A JWT is self-contained and is never stored, so there is nothing to delete. A token that still verifies for this server responds with400 unsupported_token_type(RFC7009 §2.2.1), making clear that no server-side revocation happened. A JWT that is already expired or carries an audience rejected by the OAuth resource model is treated as an invalid token and returns a200no-op.
Because a JWT access_token cannot be revoked individually, plan for it:
- Keep its lifetime short. Use
accessTokenExpiresIn, andm2mAccessTokenExpiresInforclient_credentialstokens. - To cut a user off mid-session, end the session (sign-out, admin revoke, or back-channel logout). A JWT
access_tokenthat carries a session id (sid) is reportedactive: falseby/oauth2/introspectand rejected by/oauth2/userinfoonce that session ends, even before the token expires. - A
client_credentialsJWTaccess_tokenhas no session to end, so a short expiry is the only control available.
End Session Endpoint
OpenID Connect RP-Initiated Logout 1.0 lets a relying party ask the provider to end a user's session.
The endpoint is available to clients registered with enable_end_session: true. It accepts logout parameters in a GET query or an application/x-www-form-urlencoded POST body. Better Auth's generated client sends the same parameters as JSON. A verified id_token_hint can end its referenced session immediately. Without a valid hint, Better Auth asks the user to confirm before ending the current session. The same confirmation is required when the hint refers to a different session than the browser session.
Better Auth redirects after logout only when post_logout_redirect_uri exactly matches a registered URI. It adds state only to that verified redirect. An unregistered or query-modified URI does not cause a redirect. Browser navigation receives confirmation, success, and error pages as HTML. API callers receive the protocol response.
Enable this endpoint only for clients you trust:
import { auth } from "@/lib/auth"
await auth.api.adminCreateOAuthClient({
headers,
body: {
redirect_uris: [redirectUri],
enable_end_session: true,
}
});Better Auth protects confirmation with origin and CSRF checks plus a short-lived, signed cookie. When a current session exists, the cookie is tied to it. Successful browser logout returns a logged-out page, or the Front-Channel Logout page when registered relying parties must be notified in the browser. Session deletion continues through the normal hooks, including Back-Channel Logout delivery to registered relying parties.
Back-Channel Logout
Back-Channel Logout is the server-to-server counterpart to RP-Initiated Logout: when a user's session ends at the OP (sign-out, /oauth2/end-session, admin revoke, etc.), the OP POSTs a signed Logout Token to each registered Relying Party so they can terminate their own session state and revoke bound API access.
To opt a client in, register a backchannel_logout_uri:
await auth.api.adminCreateOAuthClient({
headers,
body: {
redirect_uris: [redirectUri],
enable_end_session: true,
backchannel_logout_uri: "https://rp.example.com/logout/backchannel",
backchannel_logout_session_required: true,
}
});When backchannel_logout_session_required is true, the RP requires a sid claim in every Logout Token. Every Logout Token the OP sends already includes sid, so such clients are always served.
The backchannel_logout_uri is validated at registration. Every client must use an absolute, credential-free, public https URL with no fragment; loopback and private targets are rejected even for local development. Hosts that are reserved, tunneled (NAT64, 6to4, IPv4-mapped IPv6), or cloud-metadata names are also rejected. This host check is syntactic: it does not resolve DNS, so pin or re-check the resolved address if you need protection against DNS rebinding. The same host check guards a client's jwks_uri, which separately requires https unconditionally and a trusted origin. A URI that fails validation is rejected with invalid_client_metadata. CIMD documents cannot register back-channel logout because the discovery transport is a GET-only trust boundary.
The OP enumerates clients with active tokens bound to the ending session and POSTs one logout_token to each in parallel (5s per-RP timeout, no retry per spec §2.5). It then revokes the session's tokens:
- Refresh tokens without
offline_accessare revoked; those withoffline_accessare preserved so long-lived API access can survive the browser session (spec §2.7). - Access tokens bound to the session are revoked as additional hardening; §2.7 itself only addresses refresh tokens. Introspection and
/oauth2/userinfoalso treat any token whose session has ended as inactive, so this no longer depends on the stored flag alone.
The Logout Token carries the §2.4 claims (iss, aud, iat, exp, jti, events, plus sub and sid) with typ: logout+jwt in the protected header and no nonce. Its lifetime is capped at 120 seconds, following the §4 security guidance to keep replay windows short. It is signed with the same key as ID Tokens, so any RP that validates ID Tokens through your JWKS can validate Logout Tokens without extra configuration.
Back-channel logout requires the jwt plugin. Registering a backchannel_logout_uri while disableJwtPlugin: true is rejected with invalid_client_metadata.
Delivery runs through advanced.backgroundTasks.handler when one is configured (Vercel waitUntil, Cloudflare ctx.waitUntil), so a slow RP cannot delay sign-out. Without a handler it completes inline before the sign-out response returns: reliable on persistent servers, but it can add latency when an RP is slow. Configure a handler on serverless runtimes.
The OP advertises backchannel_logout_supported: true and backchannel_logout_session_supported: true on both .well-known/openid-configuration and .well-known/oauth-authorization-server. RPs use these fields during dynamic client registration to decide whether to register a backchannel_logout_uri.
Front-Channel Logout
Front-Channel Logout notifies Relying Parties through the user's browser. When a user logs out at /oauth2/end-session, the OP renders a page with one hidden iframe per Relying Party that holds tokens on the session. Each iframe loads that RP's frontchannel_logout_uri, so the RP can clear its own session cookies.
The iframes are third-party content. Safari blocks third-party cookies, Firefox partitions them by default, and Chrome blocks them in Incognito and for users who turn them off, so an RP on a different site often cannot see its session cookie inside the iframe. Front-channel logout is reliable only when the OP and its Relying Parties share a registrable domain, such as auth.example.com and app.example.com. Use Back-Channel Logout when RPs run on other sites or when logout must not depend on the browser.
To opt a client in, register a frontchannel_logout_uri on the same scheme, host, and port as one of its redirect_uris:
await auth.api.adminCreateOAuthClient({
headers,
body: {
redirect_uris: ["https://app.example.com/api/auth/callback"],
enable_end_session: true,
frontchannel_logout_uri: "https://app.example.com/logout/frontchannel",
}
});The OP loads the URI with iss and sid query parameters added to any query it already has, replacing a registered iss or sid. sid matches the sid claim in the ID Tokens issued to that client, so the RP can ignore requests that don't match its current session. The RP endpoint should clear its session state and respond with Cache-Control: no-store. Setting frontchannel_logout_session_required: true is accepted and stored, but changes nothing, because the OP always sends both parameters.
The frontchannel_logout_uri is validated at registration and on every client update:
- It must be an absolute
httporhttpsURL with no fragment and no credentials. - Its scheme, host, and port must match one of the client's
redirect_uris, as the specification requires. A URI that matches a loopback or private-network redirect URI is accepted, because the browser loads it, not the OP. - It does not require the
jwtplugin, because nothing is signed.
A URI that fails validation is rejected with invalid_client_metadata.
When the iframes are rendered
The page is rendered only when the session actually ends during a browser navigation to /oauth2/end-session, either with a verified id_token_hint or after the user confirms logout. If a session.delete.before hook vetoes the deletion, no Relying Party is notified. When no front-channel client holds tokens on the session, the endpoint redirects or shows the logged-out page as before.
Front-channel logout does not run for:
/sign-outor admin session revocation, because no logout page is shown in the user's browser.- Fetch-style calls such as
authClient.oauth2.endSession(), which keep the JSON response. - Session expiry, which does not delete the session when it happens.
Back-Channel Logout still fires for the first two, because they delete the session. A client that registers both URIs receives both notifications, so make RP logout handlers idempotent.
After the iframes load, or after 3 seconds if an RP doesn't respond, the page redirects to the verified post_logout_redirect_uri, or shows the logged-out message when there is none. Without JavaScript, the iframes still load and a meta refresh performs the redirect. The page keeps the same Content Security Policy as the other logout pages, extended only with frame-src for the RP origins and its own script, and sends Referrer-Policy: no-referrer so the id_token_hint in the URL never reaches an RP.
The OP advertises frontchannel_logout_supported: true and frontchannel_logout_session_supported: true on both .well-known/openid-configuration and .well-known/oauth-authorization-server, whether or not the jwt plugin is enabled.
UserInfo Endpoint
The UserInfo Endpoint provides OIDC-compliant user information. Available at /oauth2/userinfo, the endpoint requires a valid access token with at least the scope openid. An access token that is expired, revoked, or bound to a session that has ended is rejected with invalid_token (401), per RFC 6750.
// Example of how a client would use the UserInfo endpoint
const response = await fetch('https://your-domain.com/api/auth/oauth2/userinfo', {
headers: {
'Authorization': 'Bearer ACCESS_TOKEN'
}
});
const userInfo = await response.json();
// userInfo contains user details based on the scopes grantedThe UserInfo endpoint returns different claims based on the scopes that were granted during authorization:
openid: Returns the user's ID (subclaim)profile: Returnsname,picture,given_name,family_nameemail: Returnsemailandemail_verified
The endpoint also honors the OIDC claims request parameter for UserInfo. Claims listed under claims.userinfo are added to the scope-requested claims when Better Auth or your custom claim logic can supply them, bounded to the names advertised in claims_supported. Missing values are omitted from the JSON response rather than returned as null or an empty string.
Per-claim selection through claims.userinfo applies to opaque access tokens. A JWT access token resolves UserInfo claims from its granted scopes, so an individually requested claim without its backing scope is omitted (OIDC Core §5.5.1). Request the backing scope, or issue an opaque access token, when a client needs a claim that no granted scope covers.
The customUserInfoClaims function receives the user object, requested scopes array, requested UserInfo claim names, and the passed access token, allowing you to add additional information to the response.
Well-Known
OpenID Configuration
Provides OpenID Connect discovery metadata at {issuer}/.well-known/openid-configuration.
This endpoint requires the scope openid.
The OAuth Provider plugin serves this endpoint automatically from the Better Auth handler. If you do not set a custom issuer, the issuer path is your basePath, such as /api/auth.
For issuers with paths, OpenID Connect uses path appending. For example, issuer https://example.com/api/auth uses /api/auth/.well-known/openid-configuration.
If your framework route does not forward this URL to auth.handler, add a route at the issuer path:
import { oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider";
import { auth } from "@/lib/auth";
export const GET = oauthProviderOpenIdConfigMetadata(auth);If you get a CORS issue when testing locally such as with the MCP Inspector, this is due to the frontend calling the endpoint instead of the backend. Add Access-Control-Allow-Methods": "GET" and "Access-Control-Allow-Origin": "*" for testing.
OAuth Authorization Server
Provides RFC 8414-compliant metadata for the authorization server.
The OAuth Provider plugin serves both path-prefixed issuer aliases automatically from the Better Auth handler:
{issuer}/.well-known/oauth-authorization-server/.well-known/oauth-authorization-server/[issuer-path]
For example, issuer https://example.com/api/auth can use /api/auth/.well-known/oauth-authorization-server or /.well-known/oauth-authorization-server/api/auth. Both return the same metadata when the request reaches auth.handler.
If your framework route does not forward one of these URLs to auth.handler, add a route and call the helper:
import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider";
import { auth } from "@/lib/auth";
export const GET = oauthProviderAuthServerMetadata(auth);If you get a CORS issue when testing locally such as with the MCP Inspector, this is due to the frontend calling the endpoint instead of the backend. Add Access-Control-Allow-Methods": "GET" and "Access-Control-Allow-Origin": "*" for testing.
API Server
This section shows how your API should verify tokens received from your clients.
Verification
Verification can be performed using verifyAccessTokenRequest available through the oauthProviderResourceClient plugin or better-auth/oauth2 package. This is the recommended resource-server API because it verifies the access token and, when the token is DPoP-bound, also verifies the request method, URL, Authorization: DPoP scheme, proof key, replay jti, and ath claim.
With better-auth package:
import {
requestToResourceInput,
verifyAccessTokenRequest,
} from "better-auth/oauth2";
export const GET = async (req: Request) => {
const payload = await verifyAccessTokenRequest(requestToResourceInput(req), {
verifyOptions: {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
},
requiredScopes: ["read:post"], // optional
});
// ...continue
}requestToResourceInput reads the Authorization and DPoP headers and the method and URL from a standard Request. Pass a plain object with those fields if your framework does not expose a Request.
With oauthProviderResourceClient plugin:
import { serverClient } from "@/lib/server-client";
export const POST = async (req: Request) => {
const payload = await serverClient.verifyAccessTokenRequest(
req,
{
verifyOptions: {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
},
requiredScopes: ["write:post"], // optional
}
);
// ...continue
}verifyBearerToken is still available when you already extracted a raw bearer token and intentionally do not accept DPoP-bound tokens on that path. It rejects DPoP-bound tokens, so prefer verifyAccessTokenRequest for any endpoint that may receive them.
DPoP verification compares the proof's htu against the request URL, and rejects replayed proofs through a jti store. Two deployment details matter:
- Behind a proxy: the proof is checked against
request.url, so a TLS-terminating or path-rewriting proxy must forward the externally visible scheme, host, and path, or legitimate proofs are rejected. - Replay protection:
verifyAccessTokenRequestdefaults to an in-memoryjtistore that is safe only for a single instance. For multi-instance or serverless resource servers, pass a shareddpop.replayStoresuch ascreateDpopReplayStore(ctx.context.internalAdapter), which records proofs in the database-backed verification store (the provider's own endpoints andrequireMcpAuthuse it by default). It requires database-backed verification storage; a secondary-storage-only deployment rejects DPoP requests rather than skipping replay protection.
JWT Verification
- Verify the token is valid:
- Validate the signature using the JWKS.
- Check the
iss(issuer) andaud(audience) claims. - Verify the
exp(expiration) and (if sent)nbfclaim.
- Validate the appropriate
scopefor each endpoint.
Opaque Access Tokens
- Send the received token to
/oauth2/introspectand assert thatactive: trueis returned. - Validate the appropriate
scopefor each endpoint.
Recommendations
The simplest approach is to only accept JWT-formatted access tokens for your API and deny opaque tokens.
Benefits:
- Fast: locally verifiable, no network call required.
- Future-proof: independent of the authorization server after issuance.
- No client secret needed: the API can validate tokens without confidential client credentials.
Accepting opaque access tokens in addition to JWT tokens is possible, but comes with trade-offs.
Benefits:
- Immediate token and client validation.
- Client does not require a
resourceparameter (depending on authorization server configuration).
Drawbacks:
- DOS: If the client is external (ie external APIs, MCP agents), opaque
access_tokenverifications can overload your authorization server. - Performance: Every received opaque
access_tokenrequires a network call to the introspection endpoint. - Secret required: Introspection typically requires a
client_secret, which public clients cannot safely provide.- NOTE: Introspection bearer token and Private Key JWT methods are not yet implemented.
Scopes vs. Permissions
- Scopes define what a client application requests on behalf of a user. They are usually coarse-grained labels included in an access token.
- Permissions define the fine-grained actions a user (or service) is actually allowed to perform on resources, typically enforced at the resource server.
In practice, you may also combine approaches depending on system complexity and how your resource server handles authorization.
Scopes and Permissions are the Same
Each scope directly represents a permission.
- Example: A scope
read:postcorresponds exactly to the permissionread:post.
Pros:
- Simple to implement and reason about.
- No extra mapping logic required.
Cons:
- Access tokens can become large if permissions are very detailed, especially with JWTs.
- Limited flexibility for future, more granular permissions.
Scopes and Permissions are Different
Scopes represent high-level access categories, and each scope maps to one or more underlying permissions.
- Example: A scope
view:postcould map to:read:post:contentread:post:metadata(but only for posts the user owns)
Pros:
- Flexible and scalable for complex systems.
- Tokens remain compact, since only scopes are included, not all permissions.
Cons:
- The resource server must resolve scopes into permissions for each request.
- Adds complexity to implementation and authorization checks.
Configuration
Redirect Screens
During the OAuth flow, users are likely redirected between pages. For example, a user may start on a login screen then redirect to a consent screen before returning to the application. The following outlines possible login flows and configurations needed to provide each flow.
To process each redirect step in the login flow, we verify the signed query provided in the initial /oauth2/authorize redirect. All parameters sent to the authorize endpoint (including any custom ones), are signed and verified.
The shouldRedirect hooks of signup, selectAccount, and postLogin, and postLogin.consentReferenceId, receive this request as query, a URLSearchParams copy, once the provider has validated it. Read a parameter the client added with query.get, and a repeatable one with query.getAll, such as query.getAll("resource") for the resources a request targets.
If your sign-in pages include custom page query parameters, they may coexist in the URL, but they should not be added to the signed oauth_query. The client plugin forwards only the parameters declared by the signed redirect.
If you utilize the Client Plugin oauthProviderClient, then the oauth_query parameter is automatically sent to every endpoint that requires it. If you have custom sign-in endpoints, you would need to manually add the window's signed query in the request body oauth_query. This should only include the signed query parameters.
Login Screen
When a user is redirected to the OIDC provider for authentication, if they are not already logged in, they will be redirected to the login page. You can customize the login page by providing a loginPage option during initialization.
oauthProvider({
loginPage: "/sign-in"
})You don't need to handle anything from your side; when a new session is created, the plugin will handle continuing the authorization flow.
Consent Screen
When a user is redirected to the OIDC provider for authentication, they may be prompted to authorize the application to access their data.
Note: Trusted clients with skip_consent: true will bypass the consent screen entirely, providing a seamless experience for first-party applications. Device approvals from oauthDeviceAuthorization() always show the consent screen, and their query also carries user_code; show it so the user can check that it matches the code on their device. CIBA requests also always show it, and their query carries the client's binding_message when the client sent one.
oauthProvider({
consentPage: "/consent"
})The plugin will redirect the user to the specified path with client_id, scope, and, when requested, claims query parameters. Verify the signed query on the server before displaying the consent screen. Use the same secret that the OAuth provider uses; auth.$context.secret resolves the configured secret, including when using versioned secrets. Keep it on the server.
import { verifyOAuthQueryParams } from "@better-auth/oauth-provider";
import { auth } from "@/lib/auth";
export async function getVerifiedConsentQuery(request: Request) {
const query = new URL(request.url).search.slice(1);
const { secret } = await auth.$context;
if (!(await verifyOAuthQueryParams(query, secret))) {
return null;
}
return new URLSearchParams(query);
}Call this helper from your server-side consent route. Return an error response when it returns null, and use the returned parameters to render the page. The signature check rejects modified or expired queries. It does not check whether the client exists, whether the user is signed in, or whether the requested scopes are valid. Use scope and claims.userinfo to display the complete access request on your consent screen. Once the user consents, call oauth2.consent to complete the authorization.
import { authClient } from "@/lib/auth-client"
const claims = new URLSearchParams(window.location.search).get("claims");
const requestedClaims = claims ? JSON.parse(claims) : undefined;
const res = await authClient.oauth2.consent({
accept: true,
// optional scopes accepted (if not sent, accepted scopes matches the original request)
scope: "openid profile email",
// optional claims accepted (if not sent, accepted claims match the original request)
claims: requestedClaims,
// optional reference the user picked, such as an organization ID
reference_id: pickedOrganizationId,
});Sign Up Account Screen
To direct users from the client to a sign up page using prompt: create, use signup.
oauthProvider({
signUp: {
page: "/sign-up",
}
})To stop sign in process to complete registration forms, use the shouldRedirect function.
import { userRegistered } from "@lib/registered";
oauthProvider({
signUp: {
page: "/sign-up",
shouldRedirect: async ({ headers }) => {
const isUserRegistered = await userRegistered(headers);
return isUserRegistered ? false : "/setup";
},
}
})Select Account Screen
When a user is redirected to the select account page during authentication, they may be prompted to select an account before consenting. To enable account selection, you must add the following configuration to your settings.
The following example uses the multi-session plugin and automatically redirects to the select-account page if more than one session is logged in:
oauthProvider({
selectAccount: {
page: "/select-account",
shouldRedirect: async ({ headers }) => {
const allSessions = await auth.api.listDeviceSessions({
headers,
})
return allSessions?.length >= 1;
},
}
})The plugin will redirect the user to the selectAccount.page. This page should prompt for account selection and upon completion of selection, should call oauth2Continue.
import { authClient } from "@/lib/auth-client"
await authClient.multiSession.setActive({
sessionToken,
});
await client.oauth2.oauth2Continue({
selected: true,
});Post Login Screen
If a requested scope requires an organization. You would need to provide all of the following options to tie the reference_id (ie organization id, team id) to the login flow. This step occurs post login and prior to consent.
The following example uses the organization plugin to automatically redirect to the select-organization page for organization specific scopes.
oauthProvider({
scopes: ["openid", "profile", "email", "read:organization"],
postLogin: {
page: "/select-organization",
shouldRedirect: async ({ session, scopes, headers, isContinuation }) => {
const userOnlyScopes = ["openid", "profile", "email", "offline_access"];
if (scopes.every((sc) => userOnlyScopes.includes(sc))) {
return false;
}
if (!session.activeOrganizationId) {
return true;
}
const organizations = await auth.api.listOrganizations({
headers,
});
if (!organizations.some((org) => org.id === session.activeOrganizationId)) {
return true;
}
// Ask on every new authorization; accept the selection on continuation
return !isContinuation && organizations.length > 1;
},
consentReferenceId: ({ session, scopes }) => {
if (scopes.includes("read:organization")) {
const activeOrganizationId = (session?.activeOrganizationId ?? undefined) as string | undefined;
if (!activeOrganizationId) {
throw new APIError("BAD_REQUEST", {
error: "set_organization",
error_description: "must set organization for these scopes",
})
}
return activeOrganizationId;
} else {
return undefined;
}
},
}
})Consent is stored per reference. An authorization reuses only the consent the user gave for the same consentReferenceId value, and returning undefined or an empty string uses only the consent given without a reference.
The plugin will redirect the user to the postLogin.page to provide a prompt for account selection. Upon completion, call authClient.oauth2.continue. Device approvals from oauthDeviceAuthorization() run this step too, and their tokens carry the referenceId it selects.
shouldRedirect runs again on continuation with isContinuation: true. Use this value to check the selected organization without asking multi-organization users to select again. It is not proof of selection: check the current session and organization membership before returning false. Each new authorization request starts with isContinuation: false.
import { authClient } from "@/lib/auth-client"
await authClient.organization.setActive({
organizationId,
});
await authClient.oauth2.continue({
postLogin: true,
});Organization from the Request or the Consent Page
A client can name the organization it needs in its authorization request, such as organization=org_123, and consentReferenceId reads it from query. Your consent page can instead let the user pick one and send it as reference_id to /oauth2/consent, which consentReferenceId receives as consent.referenceId. Neither value comes from your server, so check that the user belongs to the organization before returning it.
consent is set only when the user accepts. Until then, return undefined while no organization is known, so the consent page can ask. A client with skip_consent gets no consent page unless its request sends prompt=consent, so refuse it instead. The provider accepts repeated parameters, because some such as resource can repeat, so refuse a request whose organizations disagree.
// Clients created with `skip_consent` never reach the consent page.
const skipConsentClientIds = new Set(["internal-dashboard"]);
oauthProvider({
scopes: ["openid", "profile", "email", "read:organization"],
postLogin: {
page: "/select-organization",
shouldRedirect: () => false, // the consent page asks instead
consentReferenceId: async ({ user, scopes, query, consent }) => {
if (!scopes.includes("read:organization")) return undefined;
// The request and the consent page must agree on one organization.
const named = new Set(
[...query.getAll("organization"), consent?.referenceId].filter(Boolean),
);
if (named.size > 1) {
throw new APIError("BAD_REQUEST", { error: "invalid_request" });
}
const [organizationId] = named;
const skipsConsent = skipConsentClientIds.has(query.get("client_id") ?? "");
// Not chosen yet: let the consent page ask.
if (!organizationId && !consent && !skipsConsent) return undefined;
// `isMember` stands for your own membership lookup.
if (!organizationId || !(await isMember(user.id, organizationId))) {
throw new APIError("FORBIDDEN", { error: "access_denied" });
}
return organizationId;
},
},
})Your consent page must show the organization being approved: the one the request names, or the one the user picks and sends as reference_id, as in the Consent Screen example.
Cached Trusted Clients
For first-party applications and internal services, you can cache trusted clients for better performance. Values are cached in memory for all mentioned clients. Additionally, they prevent changes through the CRUD endpoints.
oauthProvider({
// List of clientIds of the clients
cachedTrustedClients: new Set([
"internal-dashboard",
"mobile-app",
]),
})Resources
A list of protected resources this OAuth server issues access tokens for. Each identifier is the RFC 8707 resource parameter value and becomes the JWT aud claim when a JWT access token is issued.
oauthProvider({
resources: [
"https://api.example.com",
{
identifier: "https://api.example.com/mcp",
allowedScopes: ["mcp:read", "mcp:write"],
accessTokenTtl: 300,
},
]
})Use the admin resource endpoints when resource policy needs to change at runtime.
Configured resources are written to the oauthResource table when the auth instance starts. resourceSeedMode decides what happens to a row that already exists. If the write fails because the database is unreachable or the table has not been migrated yet, the error is logged and the write is retried on the first request that needs a resource. Requests that do not use resources, such as sign-in and session checks, are not affected.
With Prisma on PostgreSQL, CockroachDB, or MongoDB, a resource without allowedScopes cannot be stored, because Prisma cannot write null to a list column. Requests that use any configured resource fail until you declare allowedScopes for that resource, listing every scope clients may request for it. This includes the resource mcp() adds; declare it in resources with the same identifier:
mcp({
loginPage: "/sign-in",
consentPage: "/consent",
resource: "https://api.example.com/mcp",
resources: [
{ identifier: "https://api.example.com/mcp", allowedScopes: ["openid", "profile", "offline_access"] },
],
})Dynamic registration can attach protected resources to the new client in the same transaction as the client row. clientRegistrationDefaultResources adds server-owned defaults to every registration. clientRegistrationAllowedResources lists additional resources a client may request; the effective allowlist is the union of the default and allowed lists. Defaults appear first, duplicates are removed, and every configured value must also be present in resources.
oauthProvider({
resources: [
"https://api.example.com/default",
"https://api.example.com/optional",
],
clientRegistrationDefaultResources: [
"https://api.example.com/default",
],
clientRegistrationAllowedResources: [
"https://api.example.com/optional",
],
})Dynamic-registration resource requests are closed by default: when both registration-resource options are omitted, no resource may be requested. A requested resource outside the effective allowlist is rejected with invalid_target; missing and disabled resources are rejected too. Registrations with resolved resources return the final resources list and create the corresponding oauthClientResource links atomically.
To link resources to a client your server registers, pass resources to the server-only adminCreateOAuthClient call. The dynamic-registration allowlist does not apply here. Instead, resourcePrivileges is called with the link action for each requested resource before the client is created. clientRegistrationDefaultResources are still added, without a privilege check.
await auth.api.adminCreateOAuthClient({
headers,
body: {
redirect_uris: ["https://app.example.com/callback"],
resources: ["https://api.example.com/default"],
},
});The call creates nothing when a resource is rejected:
- A resource that is not an absolute URI, contains a fragment, or uses a
javascript:,data:, orvbscript:scheme fails request validation withVALIDATION_ERROR. - A resource that does not exist or is disabled returns
400withinvalid_target. - A
resourcePrivilegesdenial returns401.
The client and its resource links are written in one transaction when the database adapter supports transactions. On an adapter without them, such as standalone MongoDB, a failed link can leave the client registered without its links. To link a resource to an existing client, use adminLinkClientResource.
Scopes
Scopes allow clients specific access to specific resources. By default, we support the following scopes are supported:
openid: Returns the user's ID (subclaim).profile: Returns name, picture, given_name, family_name from UserInfoemail: Returns email and email_verified from UserInfooffline_access: Returns a refresh token
The scopes configuration can contain as many or as few scopes as you wish! Note that openid is required to be considered an OIDC server, otherwise this is a standard OAuth 2.1 server. All supported scopes must be in this array.
oauthProvider({
scopes: [ "openid", "profile", "offline_access", "read:post", "write:post" ],
})Custom Redirect URI Validation
By default, a requested redirect URI must exactly match one registered for the client. For a native client's registered HTTP loopback URI on localhost, 127.0.0.1, or [::1], the port may vary; the rest of the URI must match. RFC 8252 recommends an IP literal instead of localhost.
Use validateRedirectUri when a client needs a callback URI that cannot be registered in advance. The callback receives the requested URI, that client's registered URIs, and defaultResult, which is the result of the built-in matching. It may return a boolean or a promise. Returning true accepts the URI, even when defaultResult is false; returning false or throwing rejects it. When the option is omitted, only the built-in matching applies.
To accept preview callbacks for one web client, register the client with a concrete URI such as https://app.example.com/api/auth/callback. The callback does not change registration, and it applies to every client of this OAuth provider. This example extends the default rules only for clients registered with that URI:
oauthProvider({
validateRedirectUri: (uri, registeredUris, defaultResult) => {
if (defaultResult) return true;
if (!registeredUris.includes('https://app.example.com/api/auth/callback')) {
return false;
}
if (uri.includes('#')) return false;
try {
const url = new URL(uri);
return (
url.protocol === 'https:' &&
url.username === '' &&
url.password === '' &&
url.hostname.endsWith('.preview.example.com') &&
url.port === '' &&
url.pathname === '/api/auth/callback' &&
url.search === ''
);
} catch {
return false;
}
},
})The early defaultResult return preserves registered URI and native loopback behavior. To restrict those defaults, check defaultResult together with your additional policy. To replace them, return the result of your own validation without using defaultResult.
Custom acceptance relaxes the exact redirect URI matching required by OAuth security guidance. Use it only for callback hosts you control, and ensure untrusted contributors cannot deploy a callback under an accepted hostname. The example assumes only trusted clients can register its production callback URI. If untrusted clients can register arbitrary URIs, membership in registeredUris does not prove client ownership.
For newly accepted web callbacks, pin the HTTPS scheme, hostname suffix, port, path, and query. Avoid broad patterns such as * or *.com. Preserve the built-in checks for native callbacks unless you intend to replace them. The provider rejects malformed URIs, fragments, credentials, and javascript:, data:, or vbscript: schemes before the callback can accept them.
The callback also decides whether early authorization errors may be redirected to the requested redirect_uri (RFC 6749 §4.1.2.1). On rejection or exception, errors go to the server error page.
If you use pairwise subject identifiers, the sector identifier comes from the client's first registered redirect URI. Every preview or tenant host accepted for that client therefore uses the same sub. sector_identifier_uri is not supported. If tenants need distinct subject identifiers, register separate clients with distinct redirect URI hosts.
Claims
Internally supported claims include ["sub", "iss", "aud", "exp", "iat", "sid", "scope", "azp"].
ID token and UserInfo claims should be namespaced when possible to avoid potential future conflicts. In the authorization code flow, profile, email, and claims.userinfo request UserInfo claims; they are not added to the ID token unless you add them with customIdTokenClaims.
customIdTokenClaims is additive for protocol-owned ID token claims. Reserved names such as iss, sub, aud, token lifetime claims, nonce, sid, hash claims, auth_time, acr, amr, and azp are stripped at issuance with a warning log. Use namespaced claim names such as https://example.com/org for application-specific data.
Claims added inside customIdTokenClaims and customUserInfoClaims should be added to the advertisedMetadata.claims_supported so clients can validate that claim received. In the following example, it would be the base claims plus locale and https://example.com/org.
Pro tip: these functions can may also throw errors such as a user is no longer a member of the organization or no longer has the requested permissions.
oauthProvider({
// Attach claims to id tokens
customIdTokenClaims: ({ user, scopes, metadata }) => {
return {
locale: "en-GB",
};
},
// Attach claims to access tokens
customAccessTokenClaims: ({ user, scopes, referenceId, resources, metadata }) => {
return {
"https://example.com/org": referenceId,
"https://example.com/resources": resources,
"https://example.com/roles": ["editor"],
};
},
// Additional user info claims
customUserInfoClaims: ({ user, scopes, requestedClaims, jwt }) => {
return {
locale: "en-GB",
...(requestedClaims.includes("website")
? { website: "https://example.com" }
: {}),
};
},
})Client-Specific Access Token Claims
Use an extensions[].claims.accessToken contributor when a claim depends on the client a token is issued to. customAccessTokenClaims receives only the client's metadata, while a contributor receives the full client record, including its clientId and referenceId.
This example adds the client's organization to tokens for one API. It assumes clientReference assigns each client to an organization, and that clients are linked to the API resource, for example with resources on adminCreateOAuthClient:
const apiResource = "https://api.example.com";
oauthProvider({
resources: [apiResource],
extensions: [{
claims: {
accessToken: ({ client, resources }) => {
if (!resources?.includes(apiResource) || !client.referenceId) return {};
return { "https://example.com/org": client.referenceId };
},
},
}],
})client.referenceId is the client's owner, set at registration by clientReference or by an initial access token during protected dynamic registration. It is not the top-level referenceId that both callbacks also receive, which is the consent reference from postLogin.consentReferenceId.
When postLogin.consentReferenceId returns a consent reference, the provider also puts it in the access token as the reference_id claim, and introspection returns it for opaque access tokens and refresh tokens. The provider owns this claim, so a claim contributor cannot set or override it. Token exchange uses it to keep the reference on exchanged tokens. Resource servers can read it too, but a claim you derive in customAccessTokenClaims keeps your own name and shape.
Opaque-token introspection re-derives contributor claims without grantType or sessionId, so base any claim needed at introspection on the client, scopes, and resources. customAccessTokenClaims and per-resource customClaims override a contributor claim with the same name; see Claim precedence.
An organization claim does not prove that the client is still connected. A JWT keeps its claims until it expires, so a resource server that must react to a disabled client right away should introspect the token, which reports tokens of disabled clients as inactive.
Custom Token Response Fields
Unlike the claim callbacks above (which add data inside JWT payloads), customTokenResponseFields adds fields to the token endpoint JSON response alongside access_token, token_type, etc. Standard OAuth fields cannot be overridden.
oauthProvider({
customTokenResponseFields: ({ grantType, user, scopes, metadata, verificationValue }) => {
// Add tenant context for authorization_code grants
if (grantType === "authorization_code" && verificationValue?.referenceId) {
return { tenant_id: verificationValue.referenceId };
}
return {};
},
})The callback receives the grant type, user (undefined for client_credentials), scopes, parsed client metadata, and the verification value (only for authorization_code grants). It is called before any tokens are created, so throwing an error will not leave partially-applied state.
Authentication Context
ID tokens carry acr: "0" (OIDC level 0) and no amr by default. To assert a stronger authentication class or the methods the user signed in with, set authenticationContext with both fields:
acrValuesSupportedlists theacrvalues the provider may issue. Discovery advertises it asacr_values_supported. Include"0"ifresolvecan return noacr.resolvereturns theacrandamrthe current session established. It runs once, when the user approves an authorization request or a device code with theopenidscope, and receivesuser,session,client, the approvedscopes, the requestheaders, andrequestedAcrValues(fromclaims.id_token.acrandacr_values).
Return only what the session proved. Better Auth does not record how a session signed in, so track it yourself. The example below marks sessions created by a completed two-factor challenge. Sessions created through a trusted device skip that challenge and stay unmarked.
import { betterAuth } from "better-auth";
import { jwt, twoFactor } from "better-auth/plugins";
import { oauthProvider } from "@better-auth/oauth-provider";
const MFA = "urn:example:acr:mfa";
export const auth = betterAuth({
session: {
additionalFields: {
twoFactorVerified: { type: "boolean", defaultValue: false, input: false },
},
},
databaseHooks: {
session: {
create: {
// Two-factor verification endpoints create the session only after the
// second factor succeeds.
before: async (session, ctx) => ({
data: {
...session,
twoFactorVerified:
ctx?.path?.startsWith("/two-factor/verify-") ?? false,
},
}),
},
},
},
plugins: [
twoFactor(),
jwt(),
oauthProvider({
loginPage: "/sign-in",
consentPage: "/consent",
authenticationContext: {
acrValuesSupported: ["0", MFA],
// Assert only what the session proved: the two-factor step, not how the
// first factor was done.
resolve: ({ session }) =>
session.twoFactorVerified === true
? { acr: MFA, amr: ["mfa"] }
: { acr: "0" },
},
}),
],
});How a request for a class is handled depends on whether it is essential:
- An essential request (
claims={"id_token":{"acr":{"essential":true,"value":"…"}}}) the session does not satisfy fails withaccess_deniedat the client's redirect URI. If no value inacrValuesSupportedcould satisfy it, the request fails before sign-in. - A voluntary request (
acr_values, or anacrclaim withoutessential) never fails. The ID token reports the session's currentacr.
resolve must return an acr from acrValuesSupported (or omit it to use "0"), and amr must be an array of strings. Any other result fails the authorization with server_error. To refuse an authorization yourself, throw an APIError with an OAuth error such as access_denied; the client receives that error and its error_description. For a device code, the user sees the error on your verification page instead, and the code stays pending so the user can retry.
The acr and amr are stored with the authorization code or device code and the refresh token, so ID tokens from later refreshes keep the values of the original authentication (OIDC Core §12.2). They change only when the user authorizes again.
Expirations
Each token type and grant type can independently can set a default expiration.
accessTokenExpiresIndefaults 1 hourm2mAccessTokenExpiresIndefaults 1 houridTokenExpiresIndefaults 10 hoursrefreshTokenExpiresIndefaults 30 daysrefreshTokenReuseIntervaldefaults 0 secondscodeExpiresIndefaults 10 minutesassertionMaxLifetimedefaults 5 minutes — maximum allowed lifetime forprivate_key_jwtclient assertions
Additionally, Access Tokens can set lower expirations based on scopes. This is useful for higher-privilege scopes that require shorter expiration times. The earliest expiration will take precedence. If not specified, the default will take place. Note: values should be lower than the defaults accessTokenExpiresIn and m2mAccessTokenExpiresIn.
oauthProvider({
scopeExpirations: {
"write:payments": "5m",
"read:payments": "30m",
},
})Registration
Dynamic Client Registration
Dynamic registration allows for authorized registration of both public and confidential clients.
oauthProvider({
allowDynamicClientRegistration: true,
})Unauthenticated client registration additionally allows clients to register without an authorization header. Public clients are registered with token_endpoint_auth_method: "none". Confidential clients receive a one-time client_secret in the registration response. For MCP auth support, the recommended approach is via the CIMD plugin which maintains the identity of public clients.
oauthProvider({
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
})For MCP public-client identity, use the CIMD plugin.
Protected dynamic client registration allows machine callers to register public or confidential clients without a Better Auth user session. Issue an RFC 7591 initial access token out of band, then validate the token from the Authorization: Bearer <token> header. Defining validateInitialAccessToken enables this path; while it is undefined, a Bearer token sent to the registration endpoint is rejected.
import { createHash, timingSafeEqual } from "node:crypto"
const digest = (value: string) => createHash("sha256").update(value).digest()
oauthProvider({
allowDynamicClientRegistration: true,
validateInitialAccessToken: async ({ initialAccessToken, clientMetadata }) => {
// Compare in constant time; hashing both sides keeps the lengths equal.
const expected = digest(process.env.CLIENT_REGISTRATION_TOKEN ?? "")
if (!timingSafeEqual(digest(initialAccessToken), expected)) {
return false
}
return {
referenceId: "infra-provisioner",
}
},
})Return an object with a referenceId to set the client's owner, such as an organization ID, or false to reject the token. Without referenceId, the client has no owner. Return clientCredentialsScopes too to let the client use the client_credentials grant: it registers with that scope ceiling, as if an administrator had assigned it. Return only scopes the token's issuer approved, since the token is the only record of that approval. Registration fails unless the client registers the client_credentials grant with a confidential authentication method and every scope is one the provider offers and not user-delegated (openid, profile, email, offline_access). The registration request itself can never set these scopes. Treat clientMetadata as untrusted: it is the submitted request, not yet fully validated.
Token issuance, expiration, and revocation stay in your application because RFC 7591 leaves initial access token lifecycle policy to the authorization server. This is separate from RFC 7592 registration management tokens.
With the Bearer plugin enabled, an Authorization: Bearer value that resolves to a valid user session is handled as that session, not as an initial access token.
Enrolling Agents and Devices
Use this when software of yours runs outside your service and must call your API for an organization on its own: a sync or monitoring agent on a customer's server, a plugin on a self-hosted platform, or a signage screen. Each install gets its own client, owned by the organization, and nobody copies a secret:
- The install starts a device authorization and shows the user code.
- An organization administrator approves it on your consent page, after picking the organization on your post-login page.
- The install receives a short-lived registration token and uses it as the initial access token to register its own client.
- The client uses
client_credentialsfrom then on, even if the administrator leaves the organization.
Approval creates a credential that acts for the whole organization and outlives the approver, so a phished approval or a stolen key costs more than a stolen user token. Don't use this flow for:
- A tool each person runs for themselves: use the device code grant directly.
- An app that serves several organizations: register one client.
- AI agents acting for users: give them each user's own tokens.
- Machines that run untrusted code, such as CI runners, or devices in public places, such as payment terminals.
- High-value scopes, such as admin or payment access. Have an administrator issue the initial access token from your app instead.
Add oauthDeviceAuthorization({ expiresIn: "5min" }), a post-login page whose consentReferenceId returns the chosen organization, and:
const registrationResource = "https://auth.example.com/connector-registration"
oauthProvider({
allowDynamicClientRegistration: true,
scopes: ["openid", "connector:register", "invoices:write"],
// Registration tokens only work here and expire after 5 minutes
resources: [{
identifier: registrationResource,
allowedScopes: ["connector:register", "invoices:write"],
accessTokenTtl: 300,
}],
// Deleting a client does not invalidate tokens it already holds
m2mAccessTokenExpiresIn: 600,
validateInitialAccessToken: async ({ initialAccessToken }) => {
// Your own helpers: verify the token, use it once, recheck the admin
const token = await verifyRegistrationToken(initialAccessToken)
if (!token || !(await markRegistrationTokenUsed(token.jti))) return false
if (!(await isOrganizationAdmin(token.sub, token.reference_id))) return false
return {
referenceId: token.reference_id,
// Only machine scopes the administrator approved
clientCredentialsScopes: token.scopes.filter((scope) => scope === "invoices:write"),
}
},
})verifyRegistrationToken verifies the access token as a JWT against auth.api.getJwks(), with your JWT plugin's issuer and registrationResource as audience, and checks that azp is the installer client and scope includes connector:register.
Create one public installer client with adminCreateOAuthClient and share its client_id with every install: token_endpoint_auth_method: "none", the urn:ietf:params:oauth:grant-type:device_code grant, scope: "connector:register invoices:write", and resources: [registrationResource]. Without the resource link, device requests fail with invalid_target.
The install requests a device code with that client_id, scope: "connector:register invoices:write" (so the administrator sees the machine scopes), and resource: registrationResource, shows the code, and polls the token endpoint. It then registers with the token as Authorization: Bearer, token_endpoint_auth_method: "private_key_jwt", its public key in jwks, and grant_types: ["client_credentials"]. Keep the private key in the platform's key store.
Keep these protections:
- Say what approval does. Your consent page must state that approving creates a credential that acts for the organization with the listed scopes and keeps working after the approver leaves.
- Approved scopes only. Take machine scopes from the token's
scope, never from the registration request. - Single use. Reject a registration token whose
jtiwas already used. - Recheck the administrator. Their role can change between approval and registration.
- Tell the organization. Notify its administrators of each new enrollment, and record who approved it (the token's
sub). - Device code phishing. Every install shares the installer client, so anyone can start a flow and send an administrator its code (RFC 8628 §5.4). Show the user code on your consent page and tell administrators to approve only a code their own install displays.
- Copied installs. A copy, such as a staging clone of the install's database, carries the original client's key. Make the copy discard it and enroll again.
Dynamic Client Registration Expiration
You can set an expiration time for how long a dynamically registered confidential client should last for. By default, dynamically registered confidential clients do not expire.
oauthProvider({
allowDynamicClientRegistration: true,
clientRegistrationClientSecretExpiration: "30d",
})Dynamic Client Registration Scopes
Registration scope metadata describes the scopes a client is capable of requesting; it is not a user authorization grant. Better Auth validates a requested scope as a subset of the operator policy, then persists the complete operator-approved capability set so later authorization can step up without re-registering the client.
Set the baseline capability list with clientRegistrationDefaultScopes. All values must be defined in scopes.
oauthProvider({
scopes: ["reader", "editor"],
clientRegistrationDefaultScopes: ["reader"],
})Add capabilities with clientRegistrationAllowedScopes. The effective set is the deterministic, deduplicated union of both lists. When both options are omitted, scopes is the effective set. A DCR or CIMD document may request a subset, but that subset does not permanently prevent a later operation-specific step-up.
oauthProvider({
scopes: ["reader", "editor"],
clientRegistrationDefaultScopes: ["reader"],
clientRegistrationAllowedScopes: ["editor"],
})PKCE Configuration
PKCE (Proof Key for Code Exchange) is a security mechanism that prevents authorization code interception attacks. This plugin follows the OAuth 2.1 specification, which requires PKCE by default for all authorization code flows.
Default Behavior
By default, PKCE is required for all clients. This provides maximum security and follows OAuth 2.1 best practices.
PKCE is always required for:
- Clients using
token_endpoint_auth_method: "none" - Authorization requests with the
offline_accessscope, unless a confidential client has opted out of PKCE and the OIDC request includes bothopenidandnonce
Per-Client PKCE Configuration
Admin-created confidential clients can opt out of the PKCE requirement if needed for compatibility:
// Register a confidential client that doesn't support PKCE
const response = await auth.api.adminCreateOAuthClient({
headers,
body: {
client_name: 'Legacy Backend Service',
redirect_uris: ['https://app.example.com/callback'],
token_endpoint_auth_method: 'client_secret_post',
grant_types: ['authorization_code'],
require_pkce: false, // Opt-out of PKCE requirement
}
});The require_pkce field:
- Defaults to
true(PKCE required) - Only applies to confidential clients
- Ignored for public clients (PKCE always required)
- Requires an OIDC request with both
openidandnoncewhenoffline_accessis requested without PKCE
Dynamic Client Registration PKCE Configuration
Dynamic client registration does not accept require_pkce from client requests. To change the server-owned default for dynamically registered confidential clients, set clientRegistrationRequirePKCE.
oauthProvider({
allowDynamicClientRegistration: true,
clientRegistrationRequirePKCE: false,
})This only applies to confidential clients created through dynamic client registration. Public clients still require PKCE. Confidential OIDC clients that request offline_access without PKCE must send both openid and nonce.
When to use require_pkce: false:
- Migrating from OAuth 2.0 with legacy confidential clients that don't support PKCE
- Backend-to-backend integrations where updating the client is not feasible
- Temporary compatibility during a phased migration
Recommendation: Keep PKCE enabled (default) whenever possible. PKCE provides defense-in-depth even for confidential clients.
Security Considerations
PKCE prevents authorization code interception attacks. Even for confidential clients with client_secret authentication, PKCE provides additional security:
- Defense in depth: Multiple security layers
- Protection against misconfiguration: Accidental secret exposure
- Future-proof: Aligns with OAuth 2.1 best practices
Only disable PKCE for confidential clients when absolutely necessary for legacy compatibility.
Unauthenticated client discovery
Some clients (notably MCP clients) need to connect to your authorization server without being registered in advance. The OAuth Provider plugin supports this through two mechanisms:
allowUnauthenticatedClientRegistration: lets anonymous callers hit/oauth2/registerto create a client at request time. Confidential registrations receive a one-timeclient_secret; public registrations usetoken_endpoint_auth_method: "none".@better-auth/cimd: an optional plugin that lets clients identify themselves by hosting a metadata document at an HTTPS URL. The URL itself becomes theclient_id; the server fetches and validates the document. Generic discovery follows Client ID Metadata Document draft-02, while the MCP 2026-07-28 profile explicitly pins draft-00 requirements.
Provider extensions
OAuth companion plugins can extend the provider without changing OAuth Provider core. Use extendOAuthProvider() from a plugin init() hook to add token grants, assertion-based client authentication methods, additive discovery metadata, token or UserInfo claims, client registration metadata, client-id discovery sources, and interaction requests. An interaction request runs through the /oauth2/authorize sign-in, account selection, post-login, and consent pages, and is approved when the user accepts consent instead of issuing an authorization code; oauthDeviceAuthorization() uses one keyed by user_code. The @better-auth/cimd plugin uses this same surface to contribute its URL-based client discovery. A discovery source provides a stable, globally unique id that is persisted as client provenance and can provide fetchClientMetadataResource for resources owned by that discovery, such as a CIMD client's jwks_uri. Changing the ID requires migrating owned client rows; removing the matching discovery makes those clients fail closed.
import type { BetterAuthPlugin } from "better-auth";
import { extendOAuthProvider } from "@better-auth/oauth-provider";
export const customOAuthExtension = () =>
({
id: "custom-oauth-extension",
init(ctx) {
extendOAuthProvider(ctx, {
grants: {
"urn:example:params:oauth:grant-type:custom": async ({
provider,
}) => {
const scopes = ["openid"];
// Passing the scopes checks them against the client's registered
// scopes. Without them, the call only authenticates the client.
const { client } = await provider.authenticateClient({ scopes });
return provider.issueTokens({
client,
scopes,
tokenResponse: {
issued_token_type:
"urn:ietf:params:oauth:token-type:access_token",
},
});
},
},
metadata: () => ({
custom_grant_supported: true,
}),
});
},
}) satisfies BetterAuthPlugin;issueTokens applies the provider's resource policy (registered and linked resources and each resource's allowedScopes) but does not re-check anything else it is given: the grant handler must validate the user, the client's registered scopes, and any grant-specific policy before calling it. Pass scopes to authenticateClient so a client cannot obtain scopes it was never registered for.
Add companion plugins such as oauthDeviceAuthorization() and oauthTokenExchange() to every betterAuth() instance that registers OAuth clients, including a separate admin or provisioning instance. Registration accepts only grant types the instance supports, so registering a client for an extension grant on an instance without that plugin fails with invalid_client_metadata and unsupported grant_type.
Extension contributions follow two disciplines:
- Dispatched kinds (
grants,clientAuthentication) must be disjoint across extensions. Registering a grant type,token_endpoint_auth_method, orclient_assertion_typethat another extension already registered is rejected at setup, so a contribution can never be silently shadowed. Extension grants and auth methods are advertised in discovery automatically. - Additive kinds (
metadata,claims) never override authorization-server core. A metadata field the provider already owns (issuer,token_endpoint,grant_types_supported, the authentication-method lists, ...) is kept, and a key two extensions both contribute resolves to the first-registered extension.
A claims contributor can add new claim names but never replaces an identity, authentication-context, reserved RFC 9068, or other provider-owned claim. To advertise the claim names an extension emits, set advertisedMetadata.claims_supported: the provider owns claims_supported and does not infer it from contributors.
Client registration metadata
An extension that defines its own client registration fields, such as CIBA's backchannel_token_delivery_mode, declares them in clientMetadata. The fields are stored in the client's metadata, and validate runs whenever a client is registered through /oauth2/register or a client metadata document, or created or updated by an admin, so a bad value is rejected with invalid_client_metadata at registration rather than when the client first uses it.
extendOAuthProvider(ctx, {
clientMetadata: {
fields: ["example_mode"],
validate: ({ metadata }) => {
if (metadata.example_mode !== undefined && metadata.example_mode !== "ok") {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description: "example_mode must be ok",
});
}
},
},
});Dynamic registration ignores top-level fields it does not recognize (RFC 7591 §2). Fields an extension declares are the exception: a registrant sends them as top-level fields and the provider stores them in metadata. A registrant cannot set any other metadata key. An admin sets the same fields inside metadata. An extension cannot declare a core client field such as redirect_uris or a server-owned field such as disabled, reference_id, or client_credentials_scopes; the provider rejects these at setup. validate receives the core registration metadata after defaults (client) and the client's metadata; read extension fields from metadata.
A client metadata document carries declared fields the same way, as top-level members of the document. The document owns those fields: each refresh replaces them in metadata, removing any it no longer declares, and keeps other metadata keys, such as ones an admin set.
Provider capabilities outside a grant
A grant handler receives a provider capability surface (getClient, authenticateClient, resolveConfirmation, issueTokens, hashToken, validateAccessToken, requireActiveAccessToken). A plugin that needs those from its own endpoints (a back-channel authorization endpoint, a polling endpoint) obtains the same object with getOAuthProviderApi(ctx, opts, grantType?), so it can resolve a client or verify a token without reaching into provider internals. Use validateAccessToken for introspection-style flows that can handle inactive payloads, and requireActiveAccessToken for protected-resource endpoints that should reject inactive or unknown tokens with an OAuth bearer challenge. Pass the grant type to mint tokens away from the token endpoint; omit it for read-only use, in which case issueTokens throws rather than mint an unlabeled grant.
When a companion endpoint accepts application/x-www-form-urlencoded, set cloneRequest: true in its endpoint options before calling authenticateClient(). Client authentication re-reads the raw form body to enforce that client credentials are not repeated or combined across authentication methods, so the endpoint must preserve a readable request after parsing the form.
To sender-constrain an issued token (RFC 7800 cnf), pass confirmation to issueTokens, or return it from a clientAuthentication strategy. The provider stamps it as the access token's cnf and marks the response token_type accordingly. cnf is authorization-server-owned and cannot be set through a claim contributor.
A grant that consumes a one-time credential, such as a device code, should resolve the binding before the claim: provider.resolveConfirmation({ client, scopes, resources, confirmation }) returns the confirmation the client proved at authentication, or verifies the request's DPoP proof by the same rules issueTokens applies. Pass the result to issueTokens as confirmation. A rejected proof then fails before the credential is consumed, so the client can retry with a valid one.
Client authentication obligations
A clientAuthentication strategy verifies the assertion against its own key source and returns the client id it proved; the provider resolves and authorizes the client record itself, so a strategy proves identity but never supplies the record. After verifying the signature it must enforce the same assertion hygiene the built-in private_key_jwt method enforces, or the provider will accept a forged or replayed assertion. Use the exported consumeClientAssertion helper to bind the assertion to the endpoint audience, require a bounded lifetime, and reject jti replays:
import { consumeClientAssertion } from "@better-auth/oauth-provider";
authenticate: async ({ ctx, opts, assertion, expectedAudience }) => {
const payload = await verifyAssertionSignature(assertion); // your key source
await consumeClientAssertion(ctx, opts, {
// Scopes the replay tombstone; the same jti may recur across distinct
// methods or clients but never within one.
namespace: `urn:example:attestation:${payload.sub}`,
payload,
expectedAudience: expectedAudience!,
});
// Return only the proven client id (and an optional `confirmation`). The
// provider resolves and authorizes the client record itself.
return { clientId: payload.sub as string };
};Claim precedence
Three claim surfaces resolve contributions in a fixed order. Across all three, third-party extension claims are strictly additive, while the operator's own first-party callbacks may override profile-style identity claims; protocol-owned identity, lifetime, binding, and authentication-context claims are always pinned or reserved by the provider.
| Token | Order (lowest to highest authority) | Pinned or reserved by provider |
|---|---|---|
| Access token | extension claims.accessToken < per-issuance accessTokenClaims < customAccessTokenClaims < per-resource customClaims | reserved RFC 9068 names (iss, sub, aud, exp, iat, jti, client_id, scope, auth_time, acr, amr) and the provider-owned cnf and reference_id, stripped before signing; the provider also sets azp and sid after contributed claims |
| ID token | subject/authentication claims < customIdTokenClaims; extension and per-issuance idTokenClaims are reserved-filtered and additive | reserved OIDC/JWT names (iss, sub, aud, exp, nbf, iat, jti, nonce, sid, at_hash, c_hash, s_hash, auth_time, acr, amr, azp) and scope-derived UserInfo claim names |
| UserInfo | scope and claims.userinfo identity claims < extension claims.userInfo (additive only) < customUserInfoClaims | sub (re-pinned last) |
Per-issuance accessTokenClaims are signed into JWT access tokens, so introspecting a JWT access token returns them. An opaque access token persists no per-issuance claims, so introspecting it does not return them. A claim that must be visible at opaque-token introspection belongs in a grant-type-stable claims.accessToken contributor, which the introspection path re-derives.
Token exchange
A service that receives a user's access token sometimes needs to call another API as that user. Forwarding the token fails the API's audience check and gives the API more access than it needs. Token exchange (RFC 8693) lets the service trade the token at the token endpoint for a new access token for the other API. The new token has the same user as sub, the downstream API as aud, and the exchanging service as client_id.
The exchanged token impersonates the user: it represents the user and does not name the service as an actor (act). A typical case is an MCP server that receives a token for itself and calls a downstream API.
Set up token exchange
Add oauthTokenExchange() after oauthProvider(), and list both the resource the service receives tokens for and the resource it calls:
import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins";
import { oauthProvider, oauthTokenExchange } from "@better-auth/oauth-provider";
export const auth = betterAuth({
plugins: [
jwt(),
oauthProvider({
loginPage: "/sign-in",
consentPage: "/consent",
resources: ["https://mcp.example.com", "https://api.example.com"],
}),
oauthTokenExchange(),
],
});The grant urn:ietf:params:oauth:grant-type:token-exchange is then advertised in grant_types_supported. RFC 8693 defines no other discovery metadata.
Register the exchanging service as a confidential client with TOKEN_EXCHANGE_GRANT_TYPE. Public clients cannot exchange tokens.
import { TOKEN_EXCHANGE_GRANT_TYPE } from "@better-auth/oauth-provider";
await auth.api.adminCreateOAuthClient({
headers,
body: {
client_name: "MCP server",
grant_types: [TOKEN_EXCHANGE_GRANT_TYPE],
token_endpoint_auth_method: "client_secret_basic",
scope: "read write",
resources: ["https://mcp.example.com", "https://api.example.com"],
skip_consent: true,
},
});The client's linked resources do two jobs here:
- Linking
https://mcp.example.comlets the client exchange tokens whose audience is that resource, which are the tokens it receives. - Linking
https://api.example.comlets the client request tokens for that resource.
skip_consent decides whether the user must have approved the exchanging client:
- With
skip_consent: true, which only an admin can set, the client may exchange any token it is allowed to receive. Use it for first-party services. - Otherwise, the user must already have a stored consent for this client that covers the requested scopes and resources, for example from an earlier authorization code flow with that client. Without one, the exchange fails with
invalid_request. A client created through dynamic registration cannot setskip_consent, so it can only reach what the user has already approved for it.
Exchange a token
The service sends the user's token as subject_token and names the target with resource:
curl https://auth.example.com/api/auth/oauth2/token \
-u "$EXCHANGING_CLIENT_ID:$EXCHANGING_CLIENT_SECRET" \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
--data-urlencode "subject_token=$USER_ACCESS_TOKEN" \
-d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
--data-urlencode resource=https://api.example.com \
-d scope=read{
"access_token": "eyJ...",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 58,
"expires_at": 1767225658,
"scope": "read"
}| Parameter | Behavior |
|---|---|
subject_token | Required. An active access token this server issued |
subject_token_type | Required. Must be urn:ietf:params:oauth:token-type:access_token |
resource | Required unless audience is sent, and may repeat (RFC 8707). Each value must be a registered, enabled resource linked to the client |
scope | Optional. Defaults to the subject token's scopes, without openid and offline_access, that the client is also registered for |
requested_token_type | Optional. Only urn:ietf:params:oauth:token-type:access_token is accepted |
actor_token, actor_token_type | Not supported. Sending either returns invalid_request |
audience | Optional, and may repeat. Each value must equal a registered resource identifier and is treated like resource |
With the JWT plugin enabled, the exchanged token is a JWT access token whose aud is the requested resource. With disableJwtPlugin: true, it is opaque and the resource is returned by introspection. The response has no refresh token and no ID token.
If the request carries a DPoP proof, the exchanged token is bound to that key and token_type is DPoP, as described in DPoP sender-constrained tokens. A proof is required when the exchanging client or the target resource requires DPoP-bound tokens.
Exchange rules
The provider applies these rules to every exchange, before your allowExchange policy:
| Rule | Why |
|---|---|
| The client is confidential and registered for the token-exchange grant | Someone who only holds a leaked token cannot exchange it |
| The subject token is an access token this server issued to a user | Tokens from other issuers and machine tokens from client_credentials are rejected |
| The client is the subject token's client, or is linked to a resource in the token's audience | A client cannot exchange a token that was never sent to it |
The requested scopes are a subset of the subject token's scopes and the client's registered scopes. openid and offline_access are never exchanged | An exchange cannot add access. An exchange is not a sign-in |
| Each target resource is registered, enabled, and linked to the client | The client's resource policy still applies, including each resource's allowedScopes |
| A client that does not skip consent needs a stored consent covering the scopes and resources | The user must have approved this client for this access |
The exchanged token expires no later than the subject token and carries the subject token's session (sid) | Repeated exchanges cannot extend access, and once the session ends, introspection reports exchanged tokens inactive |
| No refresh token or ID token is issued | The service must exchange again when the token expires |
Scope names are global across resources. A read scope on the MCP server and a read scope on the API are the same scope, so restrict what a resource accepts with its allowedScopes.
Errors
| Condition | Error |
|---|---|
Missing or invalid subject token, unsupported subject_token_type, actor_token or actor_token_type, unsupported requested_token_type, neither resource nor audience, missing consent, or a policy denial | 400 invalid_request |
A scope the subject token or the client does not have, or openid or offline_access | 400 invalid_scope |
An unknown, disabled, or unlinked resource or audience value, or the userinfo endpoint | 400 invalid_target |
| Failed client authentication | invalid_client |
| A client not registered for the token-exchange grant | 400 unauthorized_client |
Keep the organization the user approved
When postLogin.consentReferenceId picks a reference during authorization, such as the organization the user approved, the exchanged token keeps the subject token's reference. It does not switch to the user's current active organization. customAccessTokenClaims receives the same referenceId, so a claim you derive from it carries over to the downstream API, and a client that does not skip consent needs a stored consent under that same reference. The provider reads the reference from the subject token's reference_id claim, so no extra configuration is needed. An exchange cannot change the organization: to act in another one, the user authorizes the client for that organization.
Add your own policy
allowExchange runs after every built-in rule passes. Return false to deny the exchange with invalid_request, or throw an APIError to return a different OAuth error. It receives the authenticated client, the user, the requested resources and scopes, the inherited referenceId, and subjectToken, the subject token's introspection payload.
oauthTokenExchange({
allowExchange: ({ client, resources }) =>
client.clientId === process.env.MCP_SERVER_CLIENT_ID &&
resources.every((resource) => resource === "https://api.example.com"),
});Limitations
- No delegation.
actor_tokenis rejected, and exchanged tokens carry noactclaim. The exchanging service is identified only byclient_id. - No refresh tokens are issued for exchanged tokens.
- Own access tokens only. ID tokens, refresh tokens, and tokens from other issuers cannot be exchanged.
- Signing out and local JWT verification. Signing out makes exchanged tokens inactive at introspection. A resource server that verifies JWT access tokens locally against the JWKS keeps accepting them until
exp, which is never later than the subject token'sexp. - Subject tokens without a session. An access token refreshed from an
offline_accessrefresh token after the user signed out carries nosid, so a token exchanged from it is not tied to a session and lasts until itsexp. ReturnfalsefromallowExchangewhensubjectToken.sidis missing if exchanges must always end with the session. may_actis not evaluated.- Pairwise subjects. The exchanged token belongs to the exchanging service, so introspection reports that service's
subfor the user. A service that exchanges a pairwise client's token can link that client'ssubto its own. - Registration default resources.
clientRegistrationDefaultResourceslinks every dynamically registered client to those resources. Such a client then counts as a resource server for tokens whose audience is one of them, so the audience rule does not tell it apart from your own service. The consent rule still limits it to what the user approved for that client.
Organizations
OAuth Clients are tied to either a user or reference_id at registration and is immutable. If you are utilizing the organization plugin, you must ensure that the activeOrganizationId is set on your active session when you create new clients.
oauthProvider({
clientReference: ({ session }) => {
return (session?.activeOrganizationId as string | undefined) ?? undefined;
},
})To set user-specific permissions and roles on tokens see Claims.
Connecting Clients to Organizations
An organization can relate to a client in two ways, and they suit different clients:
- The organization uses the client. Many organizations can use the same client, such as a third-party app, an MCP client, or a client discovered through CIMD. The client has no owner. Each user's consent records which organization it was given for.
- The organization owns the client. The client is the organization's own integration. Its members see and manage it through the client endpoints, which can change its redirect URIs or delete it. Give ownership only to an organization that controls the client, because it then controls the client for every user.
An Organization Uses the Client
Return the organization from postLogin.consentReferenceId. The user's consent is stored for that organization, so consenting for one organization does not authorize another, and deleting it through Delete Consent removes only that organization's consent. Deleting a consent does not revoke tokens already issued. Access token callbacks receive the same value as referenceId, so tokens can carry the organization:
oauthProvider({
postLogin: {
page: "/select-organization",
// The selection outlives membership, so check the user still belongs.
shouldRedirect: async ({ session, headers }) => {
if (!session.activeOrganizationId) return true
const organizations = await auth.api.listOrganizations({ headers })
return !organizations.some((org) => org.id === session.activeOrganizationId)
},
consentReferenceId: ({ session }) =>
(session?.activeOrganizationId as string | undefined) ?? undefined,
},
customAccessTokenClaims: ({ referenceId }) => ({
"https://example.com/org": referenceId,
}),
})The client record stays the same for every organization, so this works for clients that register themselves, including open dynamic client registration and CIMD. A client can also name the organization in its authorization request, or your consent page can ask for it, as shown in Organization from the Request or the Consent Page.
An Organization Owns the Client
Set the owner when the client is registered. An owned client's referenceId is fixed, and client-specific claims can read it.
- A member registers it. With
clientReferencereturning the active organization, a client created by a signed-in member through Create Client or session-authenticated dynamic registration belongs to that organization.clientPrivilegesdecides which members may create and manage clients. - A machine registers it. For software that registers itself, such as a deployment pipeline, use protected dynamic client registration. Let an organization administrator issue an initial access token in your app, and return the organization from
validateInitialAccessToken:
oauthProvider({
allowDynamicClientRegistration: true,
// Lets the organization's members find and manage the client. The active
// organization outlives membership, so check it is still current.
clientReference: async ({ user, session }) => {
const organizationId = session?.activeOrganizationId as string | undefined
if (!user || !organizationId) return undefined
return (await isMember(user.id, organizationId)) ? organizationId : undefined
},
validateInitialAccessToken: async ({ initialAccessToken }) => {
// `findRegistrationToken` and `isMember` stand for your own lookups
const token = await findRegistrationToken(initialAccessToken)
if (!token) return false
return { referenceId: token.organizationId }
},
})An open registration made without a session or initial access token has no owner. That suits a client several organizations use, each through its own consent reference. Software that each organization installs and runs as its own client, such as a self-hosted connector, should register with an initial access token instead, so the client belongs to that organization from the start.
Client CRUD Privileges
To determine whether a logged in user has the ability to perform specific actions in client creation, you can utilize the clientPrivileges configuration setting. By default, CRUD actions are allowed for users with matching userId or clientReference.
The callback can only deny an action. Client CRUD still checks ownership after the callback, so returning true cannot give a user access to a client owned by another user or another clientReference. The one exception is scope configuration on another owner's client through adminUpdateOAuthClient: when the update changes only client_credentials_scopes, the callback's update and configure-client-credentials-scopes decisions authorize it without an ownership check.
The following is a basic example that allows all OAuth Client CRUD actions for organization owners assuming ordinary users cannot create clients:
oauthProvider({
clientPrivileges: async ({ action, headers, user, session }) => {
if (!session?.activeOrganizationId) return false;
const { data: member } = await auth.api.getActiveMember({
headers,
});
return member.role === 'owner';
},
})For actions on an existing client (read, update, delete, rotate, and configure-client-credentials-scopes), the callback also receives clientId. It is absent for create, list, and scope configuration during creation. clientId is the client_id from the request, so the client may not exist and the value proves nothing about ownership.
Use clientId to restrict individual clients among users who share a clientReference, such as the members of an organization. In this example, every member can create, list, and read the organization's clients, but only a client's maintainers can change it or configure its scopes. getClientMaintainers stands for your own lookup:
oauthProvider({
clientReference: ({ session }) => {
return (session?.activeOrganizationId as string | undefined) ?? undefined;
},
clientPrivileges: async ({ action, user, clientId }) => {
if (!user) return false;
if (action === "create" || action === "list" || action === "read") {
return true;
}
// Scope configuration during creation has no client ID, so deny it here.
if (!clientId) return false;
const maintainers = await getClientMaintainers(clientId);
return maintainers.includes(user.id);
},
})Storage
By default all secrets are hashed by default on the database. This helps protect the client_secret in case of a database leak.
- storeClientSecret: the storage method of application
client_secrets. Only whendisableJwtPlugin: true, the client secret shall rather beencrypted. - storeTokens: the storage method of token values, specifically session refresh tokens and opaque access tokens.
Rate Limiting
The OAuth Provider includes built-in rate limiting for all OAuth endpoints to protect against abuse and denial-of-service attacks.
Rate limiting is per-IP per-endpoint. Each client IP address has its own rate limit counter for each endpoint. Rate limits reset after the window period expires.
These rate limits only apply when Better Auth's global rate limiting is enabled. By default, rate limiting is only enabled in production. See Rate Limiting for global configuration.
Default limits:
| Endpoint | Window | Max Requests |
|---|---|---|
/oauth2/token | 60s | 20 |
/oauth2/authorize | 60s | 30 |
/oauth2/introspect | 60s | 100 |
/oauth2/revoke | 60s | 30 |
/oauth2/register | 60s | 5 |
/oauth2/client/rotate-keys | 60s | 5 |
/oauth2/userinfo | 60s | 60 |
You can customize the rate limits for each endpoint:
oauthProvider({
rateLimit: {
token: { window: 60, max: 20 }, // 20 requests per minute
authorize: { window: 60, max: 30 }, // 30 requests per minute
introspect: { window: 60, max: 100 }, // 100 requests per minute
revoke: { window: 60, max: 30 }, // 30 requests per minute
register: { window: 60, max: 5 }, // 5 requests per minute
rotateClientKeys: { window: 60, max: 5 }, // 5 requests per minute
userinfo: { window: 60, max: 60 }, // 60 requests per minute
},
})To remove the per-endpoint rate limit override and fall back to global rate limits, set it to false:
oauthProvider({
rateLimit: {
introspect: false, // Uses global rate limits instead of per-endpoint limits
},
})Setting an endpoint to false removes the OAuth Provider's stricter per-endpoint limit. The endpoint will still be subject to Better Auth's global rate limiting if enabled.
Refresh Token Customization
You can choose to format your session tokens in a different string format using the formatRefreshToken.
These functions allow you to add additional functionality on the refresh token itself such as refresh token encryption.
Example with change in refresh token format with backwards compatibility with original token-only format:
oauthProvider({
formatRefreshToken: {
encrypt: (token, sessionId) => {
const res = sessionId ? `1.${token}.${sessionId}` : token;
return res;
},
decrypt: (token) => {
const tokenSplit = token.split('.');
if (tokenSplit.length === 3 && tokenSplit.at(0) === '1') {
return {
token: tokenSplit.at(1),
sessionId: tokenSplit.at(2),
};
}
return { token };
},
}
})Example that encrypts the refresh token as a JWE:
import { betterAuth } from "better-auth";
import { base64url, CompactEncrypt, compactDecrypt } from "jose";
import { oauthProvider } from "@better-auth/oauth-provider";
// A random 256-bit key kept in your secret store, base64url-encoded.
// Generate one with `openssl rand -base64 32 | tr '+/' '-_' | tr -d '='`.
const key = base64url.decode(process.env.REFRESH_TOKEN_KEY!);
const alg = "A256KW";
const enc = "A256GCM";
const auth = betterAuth({
plugins: [
oauthProvider({
formatRefreshToken: {
encrypt: async (token, sessionId) => {
const value = JSON.stringify({ sessionId, token });
return new CompactEncrypt(new TextEncoder().encode(value))
.setProtectedHeader({ alg, enc })
.encrypt(key);
},
decrypt: async (token) => {
const { plaintext } = await compactDecrypt(token, key);
return JSON.parse(new TextDecoder().decode(plaintext));
},
},
}),
],
});Advertised Metadata
The metadata endpoint can be customized so that the publicized scopes and claims differ from those which the server can deliver. This can prevent showcasing all your supported scopes and claims on your metadata endpoint.
All scopes inside the advertisedMetadata section MUST be listed in scopes otherwise initialization will fail.
Better Auth advertises acr_values_supported from authenticationContext.acrValuesSupported, which defaults to ["0"]. In OIDC Core, "0" means the authentication did not meet ISO/IEC 29115 level 1. See Authentication Context to assert other classes.
Scopes
oauthProvider({
scopes: ["openid", "profile", "email", "offline_access", "read:post"],
advertisedMetadata: {
scopes_supported: ["openid", "profile", "read:post"],
},
})Claims
Claims are in addition to the internally supported claims which are automatically determined by scopes. Claims are only applicable for the OIDC (ie "openid" scope).
oauthProvider({
advertisedMetadata: {
claims_supported: ["https://example.com/roles"],
},
})Disable JWT Plugin
By default, access and id tokens can be issued and verified through the JWT plugin.
You can disable the JWT requirement in which access tokens will always be opaque and id tokens are always signed in HS256 using the client_secret. Note that disabling the JWT Plugin is still OIDC compliant, /userinfo still works and signed id_token is still provided.
Key Differences:
- Providing a valid
resourcewill always provide you with an opaque access token instead of an JWT formatted token. id_tokenis not returned for public clients, but theaccess_tokenreturned can still utilize the/oauth2/userinfoendpoint to obtain the user data.id_tokenfor a confidential client is signed by theirclient_secret.
oauthProvider({
disableJwtPlugin: true,
})Pairwise Subject Identifiers
By default, the sub (subject) claim in tokens uses the user's internal ID, which is the same across all clients. This is the public subject type per OIDC Core Section 8.
You can enable pairwise subject identifiers so each client receives a unique, unlinkable sub for the same user. This prevents relying parties from correlating users across services.
oauthProvider({
pairwiseSecret: "your-256-bit-secret",
})When pairwiseSecret is configured, the server advertises both "public" and "pairwise" in the discovery endpoint's subject_types_supported. Clients opt in by setting subject_type: "pairwise" at registration.
Per-Client Configuration
const response = await auth.api.createOAuthClient({
headers,
body: {
client_name: 'Privacy-Sensitive App',
redirect_uris: ['https://app.example.com/callback'],
token_endpoint_auth_method: 'client_secret_post',
subject_type: 'pairwise', // Enable pairwise sub for this client
}
});How It Works
Pairwise identifiers are computed using HMAC-SHA256 over the sector identifier and the user ID, keyed with pairwiseSecret. Better Auth assigns the sector when the client is created:
- A client whose redirect URIs all use
httpsgets the host of its first redirect URI, including any port. Clients on the samehttpshost share a sector and receive the same pairwisesub(per OIDC Core Section 8.1). - Any other client, such as one with an authority-free private-use (
com.example.app:/callback) or loopback (http://localhost:8787/callback) redirect URI, gets a sector of its own. Any app can register a custom scheme, and any local process can listen on a loopback port, so those redirect URIs do not identify the client.
This means:
- Two clients with different sectors always receive different
subvalues for the same user - The same client always receives the same
subfor the same user (deterministic) - Changing a client's redirect URIs recomputes its sector, so moving to another host changes the
subits users receive
Clients created before Better Auth stored sectors keep the sector of their first redirect URI host, so their users' sub does not change. Unrelated custom-scheme or loopback clients created before then can share a sector.
Pairwise sub appears in:
id_token/oauth2/userinforesponse- Token introspection (
/oauth2/introspect)
When a resource server introspects a token issued to another client, it gets the sub that the issuing client sees, not one computed for the resource server itself. So a given user always appears under the same sub for that issuing client, whichever resource server asks.
JWT access tokens always use the real user ID as sub, since resource servers may need to look up users directly.
Limitations:
sector_identifier_uriis not yet supported. Allredirect_urisfor a pairwise client must share the same host. Clients with redirect URIs on different hosts will be rejected at registration.- Pairwise clients cannot use custom-scheme redirect URIs that include a host, such as
app://rp.example.com/callback. Any app can register a custom scheme, so that host does not identify the client. pairwiseSecretmust be at least 32 characters long.- Rotating
pairwiseSecretwill change all pairwisesubvalues, breaking existing RP sessions. Treat this secret as permanent once set.
MCP
Use the @better-auth/mcp plugin when an MCP server is one of your protected resources. It builds on this OAuth Provider and adds MCP defaults, RFC 9728 protected resource metadata, and route helpers that return the authorization challenge MCP clients expect.
mcp() is the OAuth Provider for that Better Auth instance, so do not register both mcp() and oauthProvider(). It accepts the OAuth Provider options directly. Use requireMcpAuth when the MCP route shares the auth instance, or createMcpProtectedRequestHandler when the resource server runs separately.
An MCP server that calls a downstream API for the user must not pass the user's token through. Exchange it for a token to that API with token exchange instead.
The MCP plugin can also support a separate registered CLI through the device grant. MCP clients keep their discovery-driven authorization code flow, while the CLI asks the same provider for a resource-bound token through device authorization. See Add device authorization for your own CLI.
Schema
The OAuth Provider plugin adds the following tables to the database:
OAuth Client
Table Name: oauthClient
OAuth Refresh Token
Table Name: oauthRefreshToken
OAuth Access Token
Table Name: oauthAccessToken
OAuth Consent
Table Name: oauthConsent
OAuth Client Assertion
Table Name: oauthClientAssertion
Records each private_key_jwt client assertion jti so it can only be used once. The row id is a digest of the per-client assertion identifier, so a replayed or concurrent assertion collides on the primary key and the database rejects it atomically, even across multiple server processes. With advanced.database.generateId: "uuid" the digest is formatted as a UUID. This check needs string ids: with "serial", the digest cannot be stored as the id and Better Auth logs a warning. A row keeps blocking its id until deleted; expiresAt marks when removal is safe, because the assertion it guards has already expired. No scheduled job prunes these rows, so remove expired rows with your own cleanup if the table grows.
Options
Prefix
Add a prefix to opaque access tokens, refresh tokens, or client secrets. This is useful for Secret Scanners (ie. GitHub Secret Scanners, GitGuardian, Trufflehog) that may rely on the prefix to help determine the token format.
We recommend to add a prefix to each of the following prior to your first production deployment. Once deployed consider them immutable, otherwise the following generate functions as specified:
The following are available under the prefix configuration setting:
- opaqueAccessToken:
string | undefined- add a prefix onto opaque access tokens. If previously deployed, utilizegenerateOpaqueAccessTokento perform this functionality instead. - refreshToken:
string | undefined- add a prefix onto refresh tokens. If previously deployed, utilizegenerateRefreshTokento perform this functionality instead. - clientSecret::
string | undefined- add a prefix onto client secrets. If previously deployed, utilizegenerateClientSecretto perform this functionality instead.
Optimizations
To improve lookup performance, database adapters may map the field client_id on the table oauthClient to id. Note that id should support strings formatted like UUIDs and urls.