OAuth 2.1 Provider

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:

  • OAuth 2.1: Restricted security practices to OAuth 2.1
  • MCP Enabled: Support with MCP authentication
  • OIDC compatibility: OIDC-compliant with the openid scope
    • UserInfo: Endpoint providing current user details
    • id_token: JWT-signed user information
    • OIDC Logout: RP-initiated-compliant Logout
  • Dynamic Client Registration: Allow clients to register clients dynamically.
    • Public Clients: Support public clients for native mobile clients and user-agent clients (like AI)
    • Confidential Clients: Supports confidential clients for web clients
    • Trusted Clients: Configure hard-coded trusted clients with optional consent bypass.
  • JWT Plugin compatibility: required by default with an option to disable
    • JWT Signing: sign JWT tokens when requesting a resource
    • JWKS Verifiable: verify tokens remotely at the /jwks endpoint
  • Authorization Prompts: prompts that initiate specific login flows
    • Consent: Ensure consent is granted for each scope. Forcible with prompt=consent.
    • Select Account: Ensure an account is selected prior when specific scopes being granted. Forcible with prompt=select_account.
  • Resource Endpoints: Read and manage tokens.
    • Introspection: RFC7662-compliant Introspection.
    • Revocation: RFC7009-compliant Revocation.

Grants Supported

  • authorization_code: Code for user token exchange with PKCE and S256 requirements.
  • refresh_token: Issue refresh tokens and handle access token renewal using offline_access scope.
  • client_credentials: Machine to Machine tokens for API communication.

This plugin is in active development and may not be suitable for production use. Please report any issues or bugs on GitHub.

Installation

Mount the Plugin

Add the OIDC plugin to your auth config. See OIDC Configuration on how to configure the plugin.

auth.ts
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 @better-auth/cli migrate
npx @better-auth/cli generate

See the Schema section to add the fields manually.

Add ./well-known endpoints

Please add all Well-Known endpoints to your project. The locations are provided as warnings if you are unsure.

  • You MUST add the OAuth Authorization Server metadata endpoint at your issuer path (root if no path).
  • If you are using the openid scope, you MUST add the openid configuration at your issuer path (root if no path).
  • If you are using the resource server (ie for MCP), you MUST add the resource server metadata to your API, with the issuer path appended.

Create your first oauth client

Create your first confidential oauth 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 (ie. without a client secret), set token_endpoint_auth_method: "none".

Client Plugins

There exists two clients. You may wish to add one or both depending on your setup.

OAuth Client

The OAuth Client is the connecting oauthClient such a mobile or web application.

client.ts
import { createAuthClient } from "better-auth/client";
import { oauthProviderClient } from "@better-auth/oauth-provider/client"
export const authClient = createAuthClient({
  plugins: [oauthProviderClient()],
});

Resource Client

The Resource Server is a client that operates on your API server to perform actions like token verification and provide metadata.

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
});

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

In OAuth there are two types of clients:

  • Public Clients: Cannot store a client secret such as native mobile clients and user-agent clients (like AI)
  • Confidential Clients: Can store a client secret such as web clients

Get Client

To obtain client information owned by a specific user or organization use the following endpoint:

GET
/oauth2/get-client
const { data, error } = await authClient.oauth2.getClient({    query: {        client_id, // required    },});
PropDescriptionType
client_id
The OAuth client's client_id
string,

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

GET
/oauth2/public-client
const { data, error } = await authClient.oauth2.publicClient({    query: {        client_id, // required    },});
PropDescriptionType
client_id
The OAuth client's client_id
string,

List Clients

To obtain a list of clients owned by a specific user or organization, use the following endpoint:

GET
/oauth2/get-clients
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 client
  • skip_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:

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.

POST
/oauth2/update-client
const { data, error } = await authClient.oauth2.updateClient({    client_id, // required    update, // required});
PropDescriptionType
client_id
The OAuth client's client_id
string,
update
The fields to update
OAuthClient,

Restrictions on this endpoint:

  • You are unable to switch between confidential and public clients. The client type must be determined at creation.
  • You cannot update the client secret. To rotate the 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.:

await auth.api.adminUpdateOAuthClient({
  headers,
  body: {
    redirect_uris: [redirectUri],
    client_secret_expires_at: 0, 
    skip_consent: true, 
    enable_end_session: true, 
  }
});

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:

POST
/oauth2/client/rotate-secret
const { data, error } = await authClient.oauth2.client.rotateSecret({    client_id, // required});
PropDescriptionType
client_id
The OAuth client's client_id
string,

Delete Client

To delete a user or organization's client, use the following endpoint:

POST
/oauth2/delete-client
const { data, error } = await authClient.oauth2.deleteClient({    client_id, // required});
PropDescriptionType
client_id
The OAuth client's client_id
string,

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:

GET
/oauth2/get-consent
const { data, error } = await authClient.oauth2.getConsent({    query: {        id, // required    },});
PropDescriptionType
id
The consent id
string,

To obtain a list of user consents, use the following endpoint:

GET
/oauth2/get-consents
const { data, error } = await authClient.oauth2.getConsents();

To update a specific consent, use the following endpoint:

POST
/oauth2/update-consent
const { data, error } = await authClient.oauth2.updateConsent({    id, // required    update, // required});
PropDescriptionType
id
The consent id
string,
update
The values to update
OAuthConsent,

Revokes a user's consent for a specific client.

POST
/oauth2/delete-consent
const { data, error } = await authClient.oauth2.deleteConsent({    id, // required});
PropDescriptionType
id
The consent id
string,

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

Setup

To enable client registration set allowDynamicClientRegistration: true in your BetterAuth config.

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

Support for allowUnauthenticatedClientRegistration will be deprecated when the MCP protocol standardizes unauthenticated dynamic client registration. As of writing, both Client ID Metadata Documents and software_statement and jwks_uri are under debate.

auth.ts
oauthProvider({
  allowDynamicClientRegistration: true,
  allowUnauthenticatedClientRegistration: true,
  // ... other options
})

Basic Example

To register a new OIDC client, use the oauth2.register method.

const client = await client.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:

  • jwks
  • jwks_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 flows.

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.

State

We require sending a state 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.

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"

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.

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.

POST
/oauth2/consent
const { data, error } = await authClient.oauth2.consent({    accept, // required    scope,});
PropDescriptionType
accept
Accept or deny user consent for a set of scopes
boolean,
scope?
Space-separated list of accepted scopes. If not provided, the originally requested scopes are accepted.
string,

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.

POST
/oauth2/continue
const { data, error } = await authClient.oauth2.continue({    selected,    created,    postLogin,});
PropDescriptionType
selected?
Confirms an account was selected.
boolean,
created?
Confirms an account was registered
boolean,
postLogin?
Confirms completion of post login activity
boolean,

Introspect Endpoint

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.

Revoke Endpoint

RFC7009-compliant Revocation.

This endpoint revokes the provided token.

  • opaque access_token: immediately removes that access_token from the database. refresh_token is still valid.
  • JWT 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,

End Session Endpoint

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.

await auth.api.adminCreateOAuthClient({
  headers,
  body: {
    redirect_uris: [redirectUri],
    enable_end_session: true, 
  }
});

If disableJwtPlugin: true, public clients will never be able to logout using this endpoint since no id_token is sent.

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.

client-app.ts
// 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_name
  • email: Returns email and email_verified

The customUserInfoClaims function receives the user object, requested scopes array, and the passed access token, allowing you to add additional information to the response.

Well known

Openid Configuration

Provides OpenID connect discovery metadata located at /.well-known/openid-configuration.

This endpoint requires the scope openid.

You must add the configuration at the issuer path. If an issuer is unset, this will be your basePath /api/auth. If this path is not at the root and you don't have an openid-configuration already at the root, we recommend you to add one in case a client incorrectly hard-coded /.well-known/openid-configuration (ignoring the issuer path in the spec).

NOTE: For issuers with paths, OpenId utilizes path appending, thus any path on the issuer should be prepended before /.well-known/openid-configuration. If no issuer path is specified, the path should start at the root.

[issuer-path]/.well-known/openid-configuration/route.ts
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 RFC8414-compliant metadata located at /.well-known/oauth-authorization-server.

You must add the configuration at the issuer path. If an issuer is unset, this will be your basePath /api/auth.

NOTE: For issuers with paths, OAuth 2.1 Authorization Server utilizes path insertion, thus any path on the issuer should be appended after /.well-known/oauth-authorization-server. If no issuer path is specified, the path should start at the root.

/.well-known/oauth-authorization-server/[issuer-path]/route.ts
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 verifyAccessToken available through the oauthProviderResourceClient plugin or better-auth/oauth2 package.

With better-auth package:

api/[endpoint].ts
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:

api/[endpoint].ts
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
}

JWT Verification

  • Verify the token is valid:
    • Validate the signature using the JWKS.
    • Check the iss (issuer) and aud (audience) claims.
    • Verify the exp (expiration) and (if sent) nbf claim.
  • Validate the appropriate scope for each endpoint.

Opaque Access Tokens

  • Send the received token to /oauth2/introspect and assert that active: true is returned.
  • Validate the appropriate scope for 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 resource parameter (depending on authorization server configuration).

Drawbacks:

  • DOS: If the client is external (ie external APIs, MCP agents), opaque access_token verifications can overload your authorization server.
  • Performance: Every received opaque access_token requires 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:post corresponds exactly to the permission read: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:post could map to:
    • read:post:content
    • read: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.

If your sign-in pages include any custom query parameters, you may append them to the end of the signed query (ie after the sig field).

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.

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

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.

auth.ts
oauthProvider({
  consentPage: "/consent"
})

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.

consent-page.ts
const res = await client.oauth2.consent({
	accept: true,
  // optional scopes accepted (if not sent, accepted scopes matches the original request)
  scope: "openid profile email"
});

Sign Up Account Screen

To direct users from the client to a sign up page using prompt: create, use signup.

auth.ts
oauthProvider({
  signUp: {
    page: "/sign-up", 
  }
})

To stop sign in process to complete registration forms, use the shouldRedirect function.

auth.ts
import { userRegistered } from "@lib";

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:

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

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

auth.ts
oauthProvider({
  scopes: ["openid", "profile", "email", "read:organization"]
  postLogin: {
    page: "/select-organization", 
    shouldRedirect: async ({ session, scopes, headers }) => { 
      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 }) => { 
      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.

select-organization.ts
await authClient.organization.setActive({
  organizationId,
});
await client.oauth2.oauth2Continue({
  postLogin: true,
});

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.

auth.ts
oauthProvider({
  // List of clientIds of the clients
  cachedTrustedClients: new Set([
    "internal-dashboard",
    "mobile-app",
  ]),
})

Valid Audiences

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.

auth.ts
oauthProvider({
  validAudiences: [
    "https://api.example.com",
    "https://api.example.com/mcp",
  ]
})

Scopes

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_name
  • email: Returns email and email_verified
  • offline_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.

auth.ts
oauthProvider({
  scopes: [ "openid", "profile", "offline_access", "read:post", "write:post" ],
})

Claims

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.

auth.ts
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",
    };
  },
})

Expirations

Each token type and grant type can independently can set a default expiration.

  • accessTokenExpiresIn defaults 1 hour
  • m2mAccessTokenExpiresIn defaults 1 hour
  • idTokenExpiresIn defaults 10 hours
  • refreshTokenExpiresIn defaults 30 days
  • codeExpiresIn defaults 10 minutes

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.

auth.ts
oauthProvider({
  scopeExpirations: {
    "write:payments": "5m",
    "read:payments": "30m",
  },
})

Registration

Dynamic Client Registration

Dynamic registration allows for authorized registration of both public and confidential clients.

auth.ts
oauthProvider({
  allowDynamicClientRegistration: true, 
})

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.

auth.ts
oauthProvider({
  allowDynamicClientRegistration: true,
  allowUnauthenticatedClientRegistration: true, 
})

Support for allowUnauthenticatedClientRegistration will be deprecated when the MCP protocol standardizes unauthenticated dynamic client registration. As of writing, both Client ID Metadata Documents and software_statement and jwks_uri are under debate.

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.

auth.ts
oauthProvider({
  allowDynamicClientRegistration: true,
  clientRegistrationClientSecretExpiration: "30d", 
})

Dynamic Client Registration Scopes

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.

auth.ts
oauthProvider({
  scopes: ["reader", "editor"],
  clientRegistrationDefaultScopes: ["reader"], 
})

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.

auth.ts
oauthProvider({
  scopes: ["reader", "editor"],
  clientRegistrationDefaultScopes: ["reader"],
  clientRegistrationAllowedScopes: ["editor"], 
})

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.

auth.ts
oauthProvider({
  clientReference: ({ session }) => {
    return (session?.activeOrganizationId as string | undefined) ?? undefined;
  },
})

To set user-specific permissions and roles on tokens see Claims.

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 following is a basic example that allows all OAuth Client CRUD actions for organization owners assuming ordinary users cannot create clients:

auth.ts
oauthProvider({
  clientPrivileges: async ({ action, headers, user, session }) => {
    if (!session?.activeOrganizationId) return false;
    const { data: member } = await auth.api.getActiveMember({
      headers,
    });
    return member.role === 'owner';
  },
})

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 when disableJwtPlugin: true, the client secret shall rather be encrypted.
  • storeTokens: the storage method of token values, specifically session refresh tokens and opaque access tokens.

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:

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

auth.ts
import { CompactEncrypt, compactDecrypt } from 'jose'

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);
      },
    }
  })]
})

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.

Scopes

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

auth.ts
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 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.
auth.ts
oauthProvider({
  disableJwtPlugin: true, 
})

MCP

You can easily make your APIs MCP-compatible simply by adding a resource server which directs users to this OAuth 2.1 authorization server.

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.

Installation

Add Resource Server Client

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

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
});

Add OAuth Protected Resource Metadata to your API

/.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",
    },
  });
};

If you use allowUnauthenticatedClientRegistration, you must ensure that your API Server is a confidential client itself:

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.

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.

Handle MCP Errors for your API

Always verify against a specified audience, the default will compare against all validAudiences or baseUrl.

  • Using the client verifyAccessToken function

See Verification for verification examples.

  • With auth available, use the client verifyAccessToken function to automatically determine endpoints
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
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 };

Schema

The OAuth Provider plugin adds the following tables to the database:

OAuth Client

Table Name: oauthClient

Field NameTypeKeyDescription
idstringDatabase ID of the OAuth client
clientIdstringUnique identifier for each OAuth client
clientSecretstringSecret key for the OAuth client. Optional for public clients using PKCE.
disabledbooleanField that indicates if the current application is disabled
skipConsentbooleanField that indicates if the application can skip consent. You may choose to enable this for trusted applications.
enableEndSessionbooleanField that indicates if the application can logout via an id_token. You may choose to enable this for trusted applications.
scopesstring[]Scopes this client is allowed to use
userIdstringID of the client owner. (optional)
referenceIdstringID of the reference of the client owner if not a user. (optional)
createdAtDate-Timestamp of when the OAuth client was created
updatedAtDate-Timestamp of when the OAuth client was last updated
namestringName of the OAuth client
uristringWebsite Uri displayed on UI Screens
iconstringWebsite Icon displayed on UI Screens
contactsstring[]Client contact list (ie customer service emails, phone numbers) to be displayed on UI Screens
tosstring[]Client Terms of Service displayed on UI Screens
policystring[]Client Privacy policy displayed on UI Screens
softwareIdstringClient-defined software identifier. This should remain the same across multiple versions for the same piece of software.
softwareVersionstringClient-defined version number of the softwareId.
softwareStatementstringSigned JWT containing the software metadata as signed claims.
redirectUrisstring[]-Array of of redirect uris
tokenEndpointAuthMethodstringIndicator of requested authentication method for the token endpoint. Supports: ['none', 'client_secret_basic', 'client_secret_post']
grantTypesstring[]Array of supported grant types. Supports: ['authorization_code', 'client_credentials', 'refresh_token']
responseTypesstring[]Array of supported grant types. Supports: ['code']
publicbooleanIndication if the client is confidential or public
typestringType of OAuth client. Supports: ['web', 'native', 'user-agent-based']
metadatajsonAdditional metadata for the OAuth client

OAuth Refresh Token

Table Name: oauthRefreshToken

Field NameTypeKeyDescription
idstringDatabase ID of the refresh token
tokenstring-Hashed/encrypted refresh token
clientIdstringID of the OAuth client
sessionIdstringID of the user associated with the token
userIdstringID of the user associated with the token
referenceIdstringID of the consented reference
scopesstring[]-Array of granted scopes
revokedDateTimestamp when the token was revoked
createdAtDate-Timestamp when the token was created
expiresAtDate-Timestamp when the token will expire

OAuth Access Token

Table Name: oauthAccessToken

Field NameTypeKeyDescription
idstringDatabase ID of the opaque access token
tokenstring-Hashed/encrypted access token
clientIdstringID of the OAuth client
sessionIdstringID of the user associated with the token
refreshIdstringID of the refresh associated with the token
userIdstringID of the user associated with the token
referenceIdstringID of the consented reference
scopesstring[]-Array of granted scopes
createdAtDate-Timestamp when the token was created
expiresAtDate-Timestamp when the token will expire

Table Name: oauthConsent

Field NameTypeKeyDescription
idstringDatabase ID of the consent
userIdstringID of the user who gave consent
clientIdstringID of the OAuth client
referenceIdstringID of the consented reference
scopesstring-Comma-separated list of scopes consented to
createdAtDate-Timestamp of when the consent was given
updatedAtDate-Timestamp of when the consent was last updated

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, utilize generateOpaqueAccessToken to perform this functionality instead.
  • refreshToken: string | undefined - add a prefix onto refresh tokens. If previously deployed, utilize generateRefreshToken to perform this functionality instead.
  • clientSecret:: string | undefined - add a prefix onto client secrets. If previously deployed, utilize generateClientSecret to 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.

Migrations

From OIDC Provider Plugin

Configuration

  • 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 string
  • consentPage is now required
  • getConsentHTML is removed in favor of the consentPage as raw html is not a response type supported by the authorize endpoint in OAuth
  • requirePKCE is removed as PKCE is required in OAuth 2.1
  • allowPlainCodeChallengeMethod is removed as the plain code challenge is considered less secure than the default S256 method
  • customUserInfoClaims (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).
  • JWT plugin now is enabled by default. To disable the plugin, set disableJwtPlugin: true.
  • Authorization query code_challenge_method "S256" must be in caps as described by OAuth 2.1

Database

Table: oauthClient

Previously oauthApplication

  • If 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:
    • Clients with type: "public": set type: undefined, public: true, and clientSecret: undefined
    • Clients with type: "native": set public: true and clientSecret: undefined
    • Clients with type: "user-agent-based": set public: true and clientSecret: undefined
    • Clients with clientSecret: undefined: set public: true
  • redirectURLs renamed to redirectUris
  • 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.
Table: oauthAccessToken

Option 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).

  • Convert oauthAccessToken with refreshToken field into a new oauthRefreshToken entry.
{
  token: defaultHasher(refreshToken),
  expiresAt: refreshTokenExpiresAt,
  clientId: clientId,
  scopes: scopes,
  userId: userId,
  createdAt: createdAt,
  updatedAt: updatedAt,
}
  • Keep 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,
}

From MCP Plugin

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)
  • Database changes are equivalent to the From OIDC Provider Plugin section.

On this page