docs/content/docs/plugins/oauth-provider.mdx
An OAuth 2.1 Provider Plugin that allows you to turn your authentication server into an OAuth provider with OIDC compatibility allowing users and other services to authenticate with your API.
The plugin has a secured configuration by default providing ease to users unfamiliar with the details of OAuth.
Key Features:
iss parameter to prevent mix-up attacksopenid scope
resource/jwks endpointprompt=consent.prompt=select_account.Grants Supported
offline_access scope.Add the OIDC plugin to your auth config. See [Configuration Section](#configuration) on how to configure the plugin.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins";
import { oauthProvider } from "@better-auth/oauth-provider"; // [!code highlight]
const auth = betterAuth({
disabledPaths: [
"/token",
],
plugins: [
jwt(),
oauthProvider({ // [!code highlight]
loginPage: "/sign-in", // [!code highlight]
consentPage: "/consent", // [!code highlight]
// ...other options // [!code highlight]
}) // [!code highlight]
],
});
```
Run the migration or generate the schema to add the necessary fields and tables to the database.
<Tabs items={["migrate", "generate"]}>
<Tab value="migrate">
```bash
npx auth migrate
```
</Tab>
<Tab value="generate">
```bash
npx auth generate
```
</Tab>
</Tabs>
See the [Schema](#schema) section to add the fields manually.
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-server` and `/.well-known/oauth-authorization-server/[issuer-path]`.
* OpenID Connect discovery metadata is available at `{issuer}/.well-known/openid-configuration` when you use the `openid` scope.
* 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 confidential oauth client.
```ts
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`
```
<Callout type="info">
To create a public client (ie. without a client secret), set `token_endpoint_auth_method: "none"`.
</Callout>
There exists two clients. You may wish to add one or both depending on your setup.
The OAuth Client is the connecting oauthClient such a mobile or web application.
import { createAuthClient } from "better-auth/client";
import { oauthProviderClient } from "@better-auth/oauth-provider/client" // [!code highlight]
export const authClient = createAuthClient({
plugins: [
oauthProviderClient(), // [!code highlight]
],
});
The Resource Server is a client that operates on your API server to perform actions like token verification and provide metadata.
import { auth } from "@/lib/auth";
import { createAuthClient } from "better-auth/client";
import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client" // [!code highlight]
export const serverClient = createAuthClient({
plugins: [
oauthProviderResourceClient(auth) // auth optional // [!code highlight]
],
});
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.
In OAuth there are two types of clients:
To obtain client information owned by a specific user or organization use the following endpoint:
<APIMethod path="/oauth2/get-client" method="GET" requireSession> ```ts type getOAuthClient = { /** * The OAuth client's client_id */ client_id: string, } ``` </APIMethod>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.:
<APIMethod path="/oauth2/public-client" method="GET" requireSession> ```ts type getOAuthClientPublic = { /** * The OAuth client's client_id */ client_id: string, } ``` </APIMethod>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.
<APIMethod path="/oauth2/public-client-prelogin" method="POST"> ```ts type getOAuthClientPublicPrelogin = { /** * The OAuth client's client_id */ client_id: string, /** * Valid oauth query parameters (Sent automatically when using the provided client) */ oauth_query: string } ``` </APIMethod>To obtain a list of clients owned by a specific user or organization, use the following endpoint:
<APIMethod path="/oauth2/get-clients" method="GET" requireSession> ```ts type getOAuthClients = { } ``` </APIMethod>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 their id_token at the /oauth2/end-session endpoint. 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, // [!code highlight]
skip_consent: true, // [!code highlight]
enable_end_session: true, // [!code highlight]
}
});
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.
Restrictions on this endpoint:
client_secret use 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, // [!code highlight]
skip_consent: true, // [!code highlight]
enable_end_session: true, // [!code highlight]
}
});
To rotate a client secret, you must use the following endpoint:
<APIMethod path="/oauth2/client/rotate-secret" method="POST" requireSession> ```ts type rotateClientSecret = { /** * The OAuth client's client_id */ client_id: string, } ``` </APIMethod>To delete a user or organization's client, use the following endpoint:
<APIMethod path="/oauth2/delete-client" method="POST" requireSession> ```ts type deleteOAuthClient = { /** * The OAuth client's client_id */ client_id: string, } ``` </APIMethod>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.
To obtain details of a specific consent, use the following endpoint:
<APIMethod path="/oauth2/get-consent" method="GET" requireSession> ```ts type getOAuthConsent = { /** * The consent id */ id: string, } ``` </APIMethod>To obtain a list of user consents, use the following endpoint:
<APIMethod path="/oauth2/get-consents" method="GET" requireSession> ```ts type getOAuthConsents = { } ``` </APIMethod>To update a specific consent, use the following endpoint:
<APIMethod path="/oauth2/update-consent" method="POST" requireSession> ```ts type updateOAuthClient = { /** * The consent id */ id: string, /** * The values to update */ update: OAuthConsent, } ``` </APIMethod>Revokes a user's consent for a specific client.
<APIMethod path="/oauth2/delete-consent" method="POST" requireSession> ```ts type deleteOAuthConsent = { /** * The consent id */ id: string, } ``` </APIMethod>Once installed, you can utilize the OAuth Provider to manage authentication flows within your application.
After the 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.
To enable client registration set allowDynamicClientRegistration: true in your BetterAuth config.
oauthProvider({
allowDynamicClientRegistration: true,
// ... other options
})
To enable unauthenticated client registration which allows for dynamically registered public clients, additionally set allowUnauthenticatedClientRegistration: true in your auth config.
oauthProvider({
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
// ... other options
})
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.
Note the following parameters are not yet supported:
jwksjwks_uriAn 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 flows.
Important notes:
response_type: "code" is supported.code_challenge_method: "plain" will not be supported since this is a security vulnerability.iss parameter for issuer validation (RFC 9207).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.
By default, the token endpoint supports providing tokens for the following grants:
The authorization code grant enables clients to obtain access user access tokens and optionally refresh tokens (with the "offline_access" scope).
The client credentials grant enables clients to obtain machines to obtain access tokens.
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.
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.
<APIMethod path="/oauth2/consent" method="POST" requireSession> ```ts type oauth2Consent = { /** * Accept or deny user consent for a set of scopes */ accept: boolean, /** * Space-separated list of accepted scopes. If not provided, the originally requested scopes are accepted. */ scope?: string, } ``` </APIMethod>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.
<APIMethod path="/oauth2/continue" method="POST" requireSession> ```ts type oauth2Continue = { /** * Confirms an account was selected. */ selected?: boolean, /** * Confirms an account was registered */ created?: boolean, /** * Confirms completion of post login activity */ postLogin?: boolean, } ``` </APIMethod>RFC7662-compliant Introspection.
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.
To provide resource specific claims via customAccessTokenClaims, store the allowed resources that a confidential client can use in its resources field.
RFC7009-compliant Revocation.
This endpoint revokes the provided token.
access_token: immediately removes that access_token from the database. refresh_token is still valid.access_token: verifies that token is safe to remove from client storage.refresh_token: removes all access_tokens granted using that refresh_token and removes the refresh_token to prevent further token issuance.For an access_token type,
RP-initiated-compliant Logout
This endpoint allows specified trusted clients to logout remotely.
To allow rp-initiated logout, a trusted client must specifically be created to perform session logout.
import { auth } from "@/lib/auth"
await auth.api.adminCreateOAuthClient({
headers,
body: {
redirect_uris: [redirectUri],
enable_end_session: true, // [!code highlight]
}
});
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.
// 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 granted
The UserInfo endpoint returns different claims based on the scopes that were granted during authorization:
openid: Returns the user's ID (sub claim)profile: Returns name, picture, given_name, family_nameemail: Returns email and email_verifiedThe customUserInfoClaims function receives the user object, requested scopes array, and the passed access token, allowing you to add additional information to the response.
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);
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);
This section shows how your API should verify tokens received from your clients.
Verification can be performed using verifyAccessToken available through the oauthProviderResourceClient plugin or better-auth/oauth2 package.
With better-auth package:
import { verifyAccessToken } from "better-auth/oauth2";
export const GET = async (req: Request) => {
const authorization = req.headers?.get("authorization") ?? undefined;
const accessToken = authorization?.startsWith("Bearer ")
? authorization.replace("Bearer ", "")
: authorization;
const payload = await verifyAccessToken(
accessToken, {
verifyOptions: {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
},
scopes: ["read:post"], // optional
}
);
// ...continue
}
With oauthProviderResourceClient plugin:
import { serverClient } from "@/lib/server-client";
export const POST = async (req: Request) => {
const authorization = req.headers?.get("authorization") ?? undefined;
const accessToken = authorization?.startsWith("Bearer ")
? authorization.replace("Bearer ", "")
: authorization;
const payload = await serverClient.verifyAccessToken(
accessToken, {
verifyOptions: {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
},
scopes: ["write:post"], // optional
}
);
// ...continue
}
iss (issuer) and aud (audience) claims.exp (expiration) and (if sent) nbf claim.scope for each endpoint./oauth2/introspect and assert that active: true is returned.scope for each endpoint.The simplest approach is to only accept JWT-formatted access tokens for your API and deny opaque tokens.
Benefits:
Accepting opaque access tokens in addition to JWT tokens is possible, but comes with trade-offs.
Benefits:
resource parameter (depending on authorization server configuration).Drawbacks:
access_token verifications can overload your authorization server.access_token requires a network call to the introspection endpoint.client_secret, which public clients cannot safely provide.
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.
read:post corresponds exactly to the permission read:post.Pros:
Cons:
Scopes and Permissions are Different
Scopes represent high-level access categories, and each scope maps to one or more underlying permissions.
view:post could map to:
read:post:contentread:post:metadata (but only for posts the user owns)Pros:
Cons:
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.
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.
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" // [!code highlight]
})
You don't need to handle anything from your side; when a new session is created, the plugin will handle continuing the authorization flow.
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 skipConsent: true will bypass the consent screen entirely, providing a seamless experience for first-party applications.
oauthProvider({
consentPage: "/consent" // [!code highlight]
})
The plugin will redirect the user to the specified path with client_id and scope query parameters. You can use this information to display a custom consent screen. Once the user consents, you can call oauth2.consent to complete the authorization.
import { authClient } from "@/lib/auth-client"
const res = await authClient.oauth2.consent({
accept: true,
// optional scopes accepted (if not sent, accepted scopes matches the original request)
scope: "openid profile email"
});
To direct users from the client to a sign up page using prompt: create, use signup.
oauthProvider({
signUp: {
page: "/sign-up", // [!code highlight]
}
})
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 }) => { // [!code highlight]
const isUserRegistered = await userRegistered(headers);
return isUserRegistered ? false : "/setup";
},
}
})
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", // [!code highlight]
shouldRedirect: async ({ headers }) => { // [!code highlight]
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,
});
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", // [!code highlight]
shouldRedirect: async ({ session, scopes, headers }) => { // [!code highlight]
const userOnlyScopes = ["openid", "profile", "email", "offline_access"];
if (scopes.every((sc) => userOnlyScopes.includes(sc))) {
return false;
}
const organizations = await auth.api.listOrganizations({
headers,
});
return organizations.length > 1 || !(
organizations.length === 1 && organizations.at(0)?.id === session.activeOrganizationId
)
},
consentReferenceId: ({ session, scopes }) => { // [!code highlight]
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;
}
},
}
})
The plugin will redirect the user to the postLogin.page to provide a prompt for account selection. Upon completion, you should call oauth2Continue.
import { authClient } from "@/lib/auth-client"
await authClient.organization.setActive({
organizationId,
});
await client.oauth2.oauth2Continue({
postLogin: true,
});
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",
]),
})
A list of valid audiences (ie resources) for this oauth server. If not specified, the default audience is the baseUrl. It is recommended to specify an audience other than the baseUrl such as your API.
oauthProvider({
validAudiences: [
"https://api.example.com",
"https://api.example.com/mcp",
]
})
Scopes allow clients specific access to specific resources. By default, we support the following scopes are supported:
openid: Returns the user's ID (sub claim).profile: Returns name, picture, given_name, family_nameemail: Returns email and email_verifiedoffline_access: Returns a refresh tokenThe 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" ],
})
Internally, we support the following claims are supported: ["sub", "iss", "aud", "exp", "iat", "sid", "scope", "azp"].
Id token and user info claims should be namespaced when possible to avoid potential future conflicts.
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, resource, metadata }) => {
return {
"https://example.com/org": referenceId,
"https://example.com/roles": ["editor"],
};
},
// Additional user info claims
customUserInfoClaims: ({ user, scopes, jwt }) => {
return {
locale: "en-GB",
};
},
})
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.
Each token type and grant type can independently can set a default expiration.
accessTokenExpiresIn defaults 1 hourm2mAccessTokenExpiresIn defaults 1 houridTokenExpiresIn defaults 10 hoursrefreshTokenExpiresIn defaults 30 dayscodeExpiresIn defaults 10 minutesAdditionally, 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",
},
})
Dynamic registration allows for authorized registration of both public and confidential clients.
oauthProvider({
allowDynamicClientRegistration: true, // [!code highlight]
})
Unauthenticated client registration additionally allows for public clients (never confidential) to register without an authorization header. This is especially useful for an MCP to dynamically register themselves as a public client.
oauthProvider({
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true, // [!code highlight]
})
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", // [!code highlight]
})
To set a list of default scopes for newly registered clients when scopes parameter is not sent, set the clientRegistrationDefaultScopes field. All scopes must be defined in scopes.
oauthProvider({
scopes: ["reader", "editor"],
clientRegistrationDefaultScopes: ["reader"], // [!code highlight]
})
To also set a list of allowed scopes for newly registered clients when scopes parameter is not sent, set the clientRegistrationAllowedScopes field. These are in addition to the clientRegistrationDefaultScopes. All scopes must be defined in scopes.
oauthProvider({
scopes: ["reader", "editor"],
clientRegistrationDefaultScopes: ["reader"],
clientRegistrationAllowedScopes: ["editor"], // [!code highlight]
})
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.
By default, PKCE is required for all clients. This provides maximum security and follows OAuth 2.1 best practices.
PKCE is always required for:
offline_access scope (refresh tokens)Individual clients can opt-out of PKCE requirement during registration if needed for compatibility:
// Register a confidential client that doesn't support PKCE
const response = await auth.api.createOAuthClient({
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:
true (PKCE required)offline_access scope (PKCE always required)When to use require_pkce: false:
Recommendation: Keep PKCE enabled (default) whenever possible. PKCE provides defense-in-depth even for confidential clients.
If you're migrating from the deprecated oidc-provider plugin and have confidential clients that don't support PKCE:
For legacy clients, opt-out per-client:
Set require_pkce: false when registering clients that cannot be updated to support PKCE.
For new clients, use PKCE: New client registrations should always use PKCE (the default) for better security.
Phase out non-PKCE clients: Plan to upgrade or replace clients that don't support PKCE over time.
Monitor usage:
Track which clients have require_pkce: false for migration planning.
PKCE prevents authorization code interception attacks. Even for confidential clients with client_secret authentication, PKCE provides additional security:
Only disable PKCE for confidential clients when absolutely necessary for legacy compatibility.
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.
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 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';
},
})
By default all secrets are hashed by default on the database. This helps protect the client_secret in case of a database leak.
client_secrets. Only when disableJwtPlugin: true, the client secret shall rather be encrypted.The OAuth Provider includes built-in rate limiting for all OAuth endpoints to protect against abuse and denial-of-service attacks.
<Callout type="info"> 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. </Callout> <Callout type="warn"> 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](/docs/concepts/rate-limit) for global configuration. </Callout>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/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
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
},
})
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 };
},
}
})
Pseudocode for a token encryption method:
import { betterAuth } from "better-auth";
import { CompactEncrypt, compactDecrypt } from 'jose'
import { oauthProvider } from "@better-auth/oauth-provider";
const secret = "SOME_SECRET_OR_KEY"
const alg = "A256KW"
const enc = "A256GCM"
const auth = betterAuth({
plugins: [
oauthProvider({
formatRefreshToken: {
encrypt: (token, sessionId) {
const value = JSON.stringify({
sessionId,
token,
});
const jwe = await new CompactEncrypt(Buffer.from(value))
.setProtectedHeader({ alg, enc })
.encrypt(secret);
return jwe;
},
decrypt: (token) {
const { plaintext } = await compactDecrypt(token, secret);
const payload = new TextDecoder().decode(plaintext);
return JSON.parse(payload);
},
}
})
]
})
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.
oauthProvider({
scopes: ["openid", "profile", "email", "offline_access", "read:post"],
advertisedMetadata: {
scopes_supported: ["openid", "profile", "read:post"],
},
})
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"],
},
})
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:
resource will always provide you with an opaque access token instead of an JWT formatted token.id_token is not returned for public clients, but the access_token returned can still utilize the /oauth2/userinfo endpoint to obtain the user data.id_token for a confidential client is signed by their client_secret.oauthProvider({
disableJwtPlugin: true, // [!code highlight]
})
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", // [!code highlight]
})
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.
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
}
});
Pairwise identifiers are computed using HMAC-SHA256 over the sector identifier (the host of the client's first redirect URI) and the user ID, keyed with pairwiseSecret. This means:
sub values for the same usersub (per OIDC Core Section 8.1)sub for the same user (deterministic)Pairwise sub appears in:
id_token/oauth2/userinfo response/oauth2/introspect)JWT access tokens always use the real user ID as sub, since resource servers may need to look up users directly.
sector_identifier_uri is not yet supported. All redirect_uris for a pairwise client must share the same host. Clients with redirect URIs on different hosts will be rejected at registration.pairwiseSecret must be at least 32 characters long.pairwiseSecret will change all pairwise sub values, breaking existing RP sessions. Treat this secret as permanent once set.
</Callout>
You can easily make your APIs MCP-compatible simply by adding a resource server which directs users to this OAuth 2.1 authorization server.
<Callout type="info"> If you are using "openid" and confidential MCP clients, you cannot disable the JWT plugin since `id_token` verification may not necessarily be supported via a `client_secret`. </Callout>See [well-known endpoints](#well-known).
(Optional) If you have your auth configuration available locally, add the configuration as a parameter to the client to fill in these values and warn you about configuration errors. You can always override these values in the function call. If this is not supplied, typescript will guide you with the minimal configuration values needed.
```ts title="server-client.ts"
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
});
```
```ts title="/.well-known/oauth-protected-resource/[resource-path]/route.ts"
import { serverClient } from "@/lib/server-client";
export const GET = async () => {
const metadata = await serverClient.getProtectedResourceMetadata({
resource: "https://api.example.com", // `aud` claim
authorization_servers: ["https://auth.example.com"],
})
return new Response(JSON.stringify(metadata), {
headers: {
"Content-Type": "application/json",
"Cache-Control":
"public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
},
});
};
```
```ts
await auth.api.createOAuthClient({
headers,
body: {
redirect_uris: [redirectUri],
}
});
```
These values should be used in the verify options `remoteVerify.clientId` and `remoteVerify.clientSecret`. Additionally, `remoteVerify.introspectUrl` would be something like `${BASE_URL}/${AUTH_PATH}/oauth2/introspect`.
<Callout type="info">
If you choose to not support `allowUnauthenticatedClientRegistration` (and only `allowDynamicClientRegistration`), the MCP client (ie. ChatGPT, Anthropic, Gemini) would need to allow you to put in a public client\_id in their UI or at runtime while chatting with the AI.
</Callout>
Always verify against a specified `audience`, the default will compare against all `validAudiences` or `baseUrl`.
* Using the client `verifyAccessToken` function
See [Verification](#verification) for verification examples.
* With auth available, use the client `verifyAccessToken` function to automatically determine endpoints
```ts title="api/[endpoint].ts"
import { auth } from "@/lib/auth";
import { serverClient } from "@/lib/server-client";
export const GET = async (req: Request) => {
const authorization = req.headers?.get("authorization") ?? undefined;
const accessToken = authorization?.startsWith("Bearer ")
? authorization.replace("Bearer ", "")
: authorization;
const payload = await serverClient.verifyAccessToken(
accessToken, {
verifyOptions: {
audience: "https://api.example.com",
}
}
);
// ...continue
}
```
* Using `mcpHandler` helper
```ts title="api/[transport]/route.ts"
import { createMcpHandler } from "mcp-handler";
import { mcpHandler } from "@better-auth/oauth-provider";
import { z } from "zod";
const handler = mcpHandler({
jwksUrl: "https://auth.example.com/api/auth/jwks",
verifyOptions: {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
},
}, (req, jwt) => {
return createMcpHandler(
(server) => {
server.registerTool(
"echo", {
description: "Echo a message",
inputSchema: {
message: z.string(),
},
},
async ({ message }) => {
return {
content: [
{
type: "text",
text: `Echo: ${message}${
jwt?.sub
? ` for user ${jwt.sub}`
: ""
}`,
},
],
};
}
);
}, {
serverInfo: {
name: "demo-better-auth",
version: "1.0.0",
}
}, {
basePath: "/api",
maxDuration: 60,
verboseLogs: true,
}
)(req);
});
export { handler as GET, handler as POST, handler as DELETE };
```
The OAuth Provider plugin adds the following tables to the database:
Table Name: oauthClient
export const oauthClientTableFields = [ { name: "id", type: "string", description: "Database ID of the OAuth client", isPrimaryKey: true, }, { name: "clientId", type: "string", description: "Unique identifier for each OAuth client", isUnique: true, }, { name: "clientSecret", type: "string", description: "Secret key for the OAuth client. Optional for public clients using PKCE.", isOptional: true, }, { name: "disabled", type: "boolean", description: "Field that indicates if the current application is disabled", isOptional: true, }, { name: "skipConsent", type: "boolean", description: "Field that indicates if the application can skip consent. You may choose to enable this for trusted applications.", isOptional: true, }, { name: "enableEndSession", type: "boolean", description: "Field that indicates if the application can logout via an id_token. You may choose to enable this for trusted applications.", isOptional: true, }, { name: "subjectType", type: "string", description: 'Subject identifier type for this client. Set to "pairwise" to receive unique, unlinkable sub claims per user. Requires pairwiseSecret to be configured on the server.', isOptional: true, }, { name: "scopes", type: "string[]", description: "Scopes this client is allowed to use", isOptional: true, }, { name: "userId", type: "string", description: "ID of the client owner. (optional)", isOptional: true, isForeignKey: true, references: { model: "user", field: "id" }, }, { name: "referenceId", type: "string", description: "ID of the reference of the client owner if not a user. (optional)", isOptional: true, }, { name: "createdAt", type: "Date", description: "Timestamp of when the OAuth client was created", isOptional: true, }, { name: "updatedAt", type: "Date", description: "Timestamp of when the OAuth client was last updated", isOptional: true, }, { name: "name", type: "string", description: "Name of the OAuth client", isOptional: true, }, { name: "uri", type: "string", description: "Website Uri displayed on UI Screens", isOptional: true, }, { name: "icon", type: "string", description: "Website Icon displayed on UI Screens", isOptional: true, }, { name: "contacts", type: "string[]", description: "Client contact list (ie customer service emails, phone numbers) to be displayed on UI Screens", isOptional: true, }, { name: "tos", type: "string", description: "Client Terms of Service displayed on UI Screens", isOptional: true, }, { name: "policy", type: "string", description: "Client Privacy policy displayed on UI Screens", isOptional: true, }, { name: "softwareId", type: "string", description: "Client-defined software identifier. This should remain the same across multiple versions for the same piece of software.", isOptional: true, }, { name: "softwareVersion", type: "string", description: "Client-defined version number of the softwareId.", isOptional: true, }, { name: "softwareStatement", type: "string", description: "Signed JWT containing the software metadata as signed claims.", isOptional: true, }, { name: "redirectUris", type: "string[]", description: "Array of of redirect uris", }, { name: "postLogoutRedirectUris", type: "string[]", description: "Array of post-logout redirect URIs", isOptional: true, }, { name: "tokenEndpointAuthMethod", type: "string", description: "Indicator of requested authentication method for the token endpoint. Supports: ['none', 'client_secret_basic', 'client_secret_post']", isOptional: true, }, { name: "grantTypes", type: "string[]", description: "Array of supported grant types. Supports: ['authorization_code', 'client_credentials', 'refresh_token']", isOptional: true, }, { name: "responseTypes", type: "string[]", description: "Array of supported grant types. Supports: ['code']", isOptional: true, }, { name: "public", type: "boolean", description: "Indication if the client is confidential or public", isOptional: true, }, { name: "type", type: "string", description: "Type of OAuth client. Supports: ['web', 'native', 'user-agent-based']", isOptional: true, }, { name: "requirePKCE", type: "boolean", description: "Whether PKCE is required for this client", isOptional: true, }, { name: "metadata", type: "json", description: "Additional metadata for the OAuth client", isOptional: true, }, ];
<DatabaseTable name="oauthClient" fields={oauthClientTableFields} />Table Name: oauthRefreshToken
export const oauthRefreshTokenTableFields = [ { name: "id", type: "string", description: "Database ID of the refresh token", isPrimaryKey: true, }, { name: "token", type: "string", description: "Hashed/encrypted refresh token", }, { name: "clientId", type: "string", description: "ID of the OAuth client", isForeignKey: true, references: { model: "oauthClient", field: "clientId" }, }, { name: "sessionId", type: "string", description: "ID of the session used at issuance of the token (and still active)", isForeignKey: true, isOptional: true, references: { model: "session", field: "id", onDelete: "set null" }, }, { name: "userId", type: "string", description: "ID of the user associated with the token", isForeignKey: true, references: { model: "user", field: "id" }, }, { name: "referenceId", type: "string", description: "ID of the consented reference", isOptional: true, }, { name: "scopes", type: "string[]", description: "Array of granted scopes", }, { name: "revoked", type: "Date", description: "Timestamp when the token was revoked", isOptional: true, }, { name: "authTime", type: "Date", description: "Original authentication time. Preserved across token rotation so refreshed ID tokens include a correct auth_time claim per OIDC Core 1.0 Section 12.2.", isOptional: true, }, { name: "createdAt", type: "Date", description: "Timestamp when the token was created", }, { name: "expiresAt", type: "Date", description: "Timestamp when the token will expire", }, ];
<DatabaseTable name="oauthRefreshToken" fields={oauthRefreshTokenTableFields} />Table Name: oauthAccessToken
export const oauthAccessTokenTableFields = [ { name: "id", type: "string", description: "Database ID of the opaque access token", isPrimaryKey: true, }, { name: "token", type: "string", description: "Hashed/encrypted access token", isUnique: true, }, { name: "clientId", type: "string", description: "ID of the OAuth client", isForeignKey: true, references: { model: "oauthClient", field: "clientId" }, }, { name: "sessionId", type: "string", description: "ID of the session used at issuance of the token (and still active)", isForeignKey: true, isOptional: true, references: { model: "session", field: "id", onDelete: "set null" }, }, { name: "refreshId", type: "string", description: "ID of the refresh associated with the token", isForeignKey: true, isOptional: true, references: { model: "oauthRefreshToken", field: "id" }, }, { name: "userId", type: "string", description: "ID of the user associated with the token", isForeignKey: true, isOptional: true, references: { model: "user", field: "id" }, }, { name: "referenceId", type: "string", description: "ID of the consented reference", isOptional: true, }, { name: "scopes", type: "string[]", description: "Array of granted scopes", }, { name: "createdAt", type: "Date", description: "Timestamp when the token was created", }, { name: "expiresAt", type: "Date", description: "Timestamp when the token will expire", }, ];
<DatabaseTable name="oauthAccessToken" fields={oauthAccessTokenTableFields} />Table Name: oauthConsent
export const oauthConsentTableFields = [ { name: "id", type: "string", description: "Database ID of the consent", isPrimaryKey: true, }, { name: "userId", type: "string", description: "ID of the user who gave consent", isForeignKey: true, references: { model: "user", field: "id" }, }, { name: "clientId", type: "string", description: "ID of the OAuth client", isForeignKey: true, references: { model: "oauthClient", field: "clientId" }, }, { name: "referenceId", type: "string", description: "ID of the consented reference", isOptional: true, }, { name: "scopes", type: "string[]", description: "Array of scopes consented to", }, { name: "createdAt", type: "Date", description: "Timestamp of when the consent was given", }, { name: "updatedAt", type: "Date", description: "Timestamp of when the consent was last updated", }, ];
<DatabaseTable name="oauthConsent" fields={oauthConsentTableFields} />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:
string | undefined - add a prefix onto opaque access tokens. If previously deployed, utilize generateOpaqueAccessToken to perform this functionality instead.string | undefined - add a prefix onto refresh tokens. If previously deployed, utilize generateRefreshToken to perform this functionality instead.string | undefined - add a prefix onto client secrets. If previously deployed, utilize generateClientSecret to perform this functionality instead.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.
See OIDC Provider Plugin for the previous implementation.
idTokenExpiresIn now defaults to 10 hours (previously 1 hour through accessTokenExpiresIn)refreshTokenExpiresIn now defaults to 30 days (previously 7 days)advertisedMetadata (previously metadata) no longer supports changing metadata fields to prevent accidental misconfiguration.clientRegistrationDefaultScopes (previously defaultScope) is now in array format instead of a space-separated stringconsentPage is now requiredgetConsentHTML is removed in favor of the consentPage as raw html is not a response type supported by the authorize endpoint in OAuthrequirePKCE (global option) is removed. PKCE is now required by default per OAuth 2.1. Individual clients can opt-out using require_pkce: false during registration if needed for legacy compatibility.allowPlainCodeChallengeMethod is removed as the plain code challenge is considered less secure than the default S256 methodcustomUserInfoClaims (previously getAdditionalUserInfoClaim) passes the jwt payload instead of the client of the access token used in the request.storeClientSecret now defaults to hashed, or encrypted if disableJwtPlugin: true (previously plain).disableJwtPlugin: true.code_challenge_method "S256" must be in caps as described by OAuth 2.1oauthClientPreviously oauthApplication
storeClientSecret was unset or plain, you must hash all the stored clientSecret values into its "SHA-256" representation then convert it into base64Url format or use another storage method specified by storeClientSecret.
The following function will convert a plain representation into the default hash:import { createHash } from "@better-auth/utils/hash";
import { base64Url } from "@better-auth/utils/base64";
const defaultHasher = async (value: string) => {
const hash = await createHash("SHA-256").digest(
new TextEncoder().encode(value),
);
const hashed = base64Url.encode(new Uint8Array(hash), {
padding: false,
});
return hashed;
};
type field is no longer a required field. Instead, the schema requires public of type boolean. Migrate with the following rules:
type: "public": set type: undefined, public: true, and clientSecret: undefinedtype: "native": set public: true and clientSecret: undefinedtype: "user-agent-based": set public: true and clientSecret: undefinedclientSecret: undefined: set public: trueredirectURLs renamed to redirectUrisrequirePkce field added (optional, defaults to true). For existing confidential clients that don't support PKCE, set requirePkce: false.metadata is now stored in database as individual fields instead of a JSON object. Parse the metadata into their respective fields. The OIDC plugin did not utilize this field but this OAuth plugin may utilize them in the future.oauthAccessTokenOption 1 (simple):
You may choose to opt-out of this table conversion with minimal impact. By doing so, users of the existing application will simply need to login again. Simply delete the existing table oauthAccessToken.
Option 2 (more complex):
Migrate all tables (you may need to create a clone of oauthAccessToken into oauthRefreshToken before a migration).
oauthAccessToken with refreshToken field into a new oauthRefreshToken entry.{
token: defaultHasher(refreshToken),
expiresAt: refreshTokenExpiresAt,
clientId: clientId,
scopes: scopes,
userId: userId,
createdAt: createdAt,
updatedAt: updatedAt,
}
oauthAccessToken but reference new oauthRefreshToken.{
token: defaultHasher(accessToken),
expiresAt: accessTokenExpiresAt,
clientId: clientId,
scopes: scopes,
refreshId: oauthRefreshToken.id, // `undefined` if no refreshToken
createdAt: createdAt,
updatedAt: updatedAt,
}
See MCP Plugin for prior MCP-specific endpoints.
The MCP endpoints moved from /mcp to the /oauth2 equivalent.
/oauth2/authorize (previously /mcp/authorize)/oauth2/token (previously /mcp/token)/oauth2/register (previously /mcp/register)/mcp/get-session removed as not OAuth 2 compliant, use /oauth2/introspect instead/.well-known/oauth-protected-resource removed, use the helper mcpHandler (or manually with the server api.oAuth2introspectVerify or the resource client verifyAccessToken)