# OAuth 2.1 Provider (/docs/plugins/oauth-provider)

A Better Auth plugin that enables your auth server to serve as an OAuth 2.1 provider.



The **OAuth 2.1 Provider** plugin turns your Better Auth server into an OAuth authorization server. Applications can request user access through the authorization code flow, while services can use client credentials. Add the `openid` scope when clients also need OpenID Connect (OIDC) identity claims.

The default configuration chooses the safer protocol behavior, including PKCE for public clients and exact redirect URI matching. Start with [Installation](#installation), then enable only the grants and registration paths your clients use.

**Key features**

* **OAuth security profile:** follows [OAuth 2.1](https://oauth.net/2.1/) practices and includes the RFC 9207 `iss` parameter to prevent [authorization-server mix-up attacks](https://datatracker.ietf.org/doc/html/rfc9207).
* **OpenID Connect:** issues ID tokens, serves UserInfo, and supports [RP-initiated logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) when clients request `openid`.
* **Client registration:** supports administrator-managed clients, first-party trusted clients, and optional [Dynamic Client Registration](#dynamic-registration-endpoint).
* **Public and confidential clients:** derives authentication from `token_endpoint_auth_method`; use `"none"` for clients that cannot keep a secret.
* **Resource-bound access:** issues tokens for protected resources, supports introspection and revocation, and exposes signing keys through the JWT plugin's [`/jwks`](/docs/plugins/jwt#verifying-the-token) endpoint.
* **Authorization prompts:** supports consent and account-selection prompts.
* **MCP composition:** use the [MCP plugin](#mcp) when the protected resource is an MCP server.

**Supported grants**

* **`authorization_code`:** exchanges a user authorization code with S256 PKCE.
* **`refresh_token`:** renews access through the `offline_access` scope.
* **`client_credentials`:** issues machine-to-machine access tokens.
* **`device_code`:** adds the optional [Device Authorization](/docs/plugins/device-authorization#authorize-a-cli-to-call-an-api) flow for CLIs and limited-input clients.

`client_credentials` is fail closed. A client's user-delegated `scope` metadata never authorizes machine access. Administrators must assign a non-empty `client_credentials_scopes` value through the administrative create or update endpoint, and `clientPrivileges` must explicitly approve the `configure-client-credentials-scopes` action. The assigned value is both the maximum requestable scope set and the default when the token request omits `scope`. DCR, CIMD, and user-managed registration can declare the grant but cannot assign this server-owned scope ceiling.

## Installation [#installation]

<Steps>
  <Step>
    ### Mount the Plugin [#mount-the-plugin]

    Add the OAuth Provider 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]
      ],
    });
    ```
  </Step>

  <Step>
    ### Migrate the Database [#migrate-the-database]

    Run the migration or generate the schema to add the necessary fields and tables to the database.

    <Tabs items="[&#x22;migrate&#x22;, &#x22;generate&#x22;]">
      <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.
  </Step>

  <Step>
    ### Confirm `/.well-known` endpoints [#confirm-well-known-endpoints]

    Better Auth serves the OAuth Authorization Server metadata and OpenID Connect discovery metadata from the auth handler automatically. If your framework only forwards requests under a catch-all auth route, make sure the issuer metadata URLs reach `auth.handler`.

    * OAuth Authorization Server metadata is available at both `{issuer}/.well-known/oauth-authorization-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.
  </Step>

  <Step>
    ### Create your first OAuth client [#create-your-first-oauth-client]

    Create a confidential 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 without a client secret, set `token_endpoint_auth_method: "none"`.
    </Callout>
  </Step>
</Steps>

## Client Plugins [#client-plugins]

Two client plugins cover different roles. Add the OAuth client when your app starts authorization flows, and add the resource client when your API verifies access tokens.

### OAuth Client [#oauth-client]

The OAuth client connects a web or native application to the authorization server.

```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client";
import { oauthProviderClient } from "@better-auth/oauth-provider/client" // [!code highlight]

export const authClient = createAuthClient({
  plugins: [
    oauthProviderClient(), // [!code highlight]
  ],
});
```

### Resource Client [#resource-client]

The resource client runs in the API that receives access tokens. It verifies those tokens and serves protected-resource metadata.

```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" // [!code highlight]

export const serverClient = createAuthClient({
  plugins: [
    oauthProviderResourceClient(auth) // auth optional // [!code highlight]
  ],
});
```

## Usage [#usage]

The plugin operates as an OAuth 2.1 server with OIDC compatible endpoints and JWT verifiable access tokens. The following provides more detailed information about each endpoint.

### OAuth Clients [#oauth-clients]

OAuth client authentication capability and application topology are separate:

* **Public Clients**: Cannot keep a client secret and use `token_endpoint_auth_method: "none"`.
* **Confidential Clients**: Authenticate at the token endpoint with a registered method such as `client_secret_basic`, `client_secret_post`, or `private_key_jwt`.
* **Application Type**: `application_type` is either `web` or `native` and controls redirect URI validation only. It does not determine whether the client is public or confidential.

#### Get Client [#get-client]

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

**Endpoint:** `GET /oauth2/get-client`

### Client Side

```ts
const { data, error } = await authClient.oauth2.getClient({
    query: {
        client_id, // required, The OAuth client's client_id
    },
});
```

### Server Side

```ts
const data = await auth.api.getOAuthClient({
    query: {
        client_id, // required, The OAuth client's client_id
    },
    // This endpoint requires session cookies.
    headers: await headers(),
});
```

### Type Definition

```ts
type getOAuthClient = {
  /**
   * The OAuth client's client_id
   */
  client_id: string,
}
```

#### Get Public Client [#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.:

**Endpoint:** `GET /oauth2/public-client`

### Client Side

```ts
const { data, error } = await authClient.oauth2.publicClient({
    query: {
        client_id, // required, The OAuth client's client_id
    },
});
```

### Server Side

```ts
const data = await auth.api.getOAuthClientPublic({
    query: {
        client_id, // required, The OAuth client's client_id
    },
    // This endpoint requires session cookies.
    headers: await headers(),
});
```

### Type Definition

```ts
type getOAuthClientPublic = {
  /**
   * The OAuth client's client_id
   */
  client_id: string,
}
```

#### Get Public Client Prelogin [#get-public-client-prelogin]

To obtain a public client prior to login, you must first enable the endpoint in your configuration:

```ts title="auth.ts"
oauthProvider({
  allowPublicClientPrelogin: true,
})
```

Then, the following endpoint will obtain public client information.

**Endpoint:** `POST /oauth2/public-client-prelogin`

### Client Side

```ts
const { data, error } = await authClient.oauth2.publicClientPrelogin({
    client_id, // required, The OAuth client's client_id
    oauth_query, // required, Valid oauth query parameters (Sent automatically when using the provided client)
});
```

### Server Side

```ts
const data = await auth.api.getOAuthClientPublicPrelogin({
    body: {
        client_id, // required, The OAuth client's client_id
        oauth_query, // required, Valid oauth query parameters (Sent automatically when using the provided client)
    },
});
```

### Type Definition

```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
}
```

#### List Clients [#list-clients]

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

**Endpoint:** `GET /oauth2/get-clients`

### Client Side

```ts
const { data, error } = await authClient.oauth2.getClients();
```

### Server Side

```ts
const data = await auth.api.getOAuthClients({
    // This endpoint requires session cookies.
    headers: await headers(),
});
```

### Type Definition

```ts
type getOAuthClients = {
}
```

#### Create Client [#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](https://datatracker.ietf.org/doc/html/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:

```ts title="admin-create-oauth.ts"
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]
  }
});
```

#### Update Client [#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](https://datatracker.ietf.org/doc/html/rfc7591).

**Endpoint:** `POST /oauth2/update-client`

### Client Side

```ts
const { data, error } = await authClient.oauth2.updateClient({
    client_id, // required, The OAuth client's client_id
    update, // required, The fields to update
});
```

### Server Side

```ts
const data = await auth.api.updateOAuthClient({
    body: {
        client_id, // required, The OAuth client's client_id
        update, // required, The fields to update
    },
    // This endpoint requires session cookies.
    headers: await headers(),
});
```

### Type Definition

```ts
type updateOAuthClient = {
  /**
   * The OAuth client's client_id
   */
  client_id: string,
  /**
   * The fields to update
   */
  update: OAuthClient,
}
```

Restrictions on this endpoint:

* You cannot change `token_endpoint_auth_method` after creation. The method selected at creation determines whether the client has credential capability.
* 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.:

```ts title="admin-update-oauth.ts"
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]
  }
});
```

#### Rotate Client Secret [#rotate-client-secret]

<Callout type="warn">
  The current implementation rotates the client secret immediately and the previous secret is invalidated immediately.
</Callout>

To rotate a client secret, you must use the following endpoint:

**Endpoint:** `POST /oauth2/client/rotate-secret`

### Client Side

```ts
const { data, error } = await authClient.oauth2.client.rotateSecret({
    client_id, // required, The OAuth client's client_id
});
```

### Server Side

```ts
const data = await auth.api.rotateClientSecret({
    body: {
        client_id, // required, The OAuth client's client_id
    },
    // This endpoint requires session cookies.
    headers: await headers(),
});
```

### Type Definition

```ts
type rotateClientSecret = {
  /**
   * The OAuth client's client_id
   */
  client_id: string,
}
```

#### Delete Client [#delete-client]

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

**Endpoint:** `POST /oauth2/delete-client`

### Client Side

```ts
const { data, error } = await authClient.oauth2.deleteClient({
    client_id, // required, The OAuth client's client_id
});
```

### Server Side

```ts
const data = await auth.api.deleteOAuthClient({
    body: {
        client_id, // required, The OAuth client's client_id
    },
    // This endpoint requires session cookies.
    headers: await headers(),
});
```

### Type Definition

```ts
type deleteOAuthClient = {
  /**
   * The OAuth client's client_id
   */
  client_id: string,
}
```

### OAuth Consent [#oauth-consent]

Consent is required on all non-trusted clients, specifically those without `skip_consent`. The following endpoints allow users or `reference_id` manage their given consents.

#### Get Consent [#get-consent]

To obtain details of a specific consent, use the following endpoint:

**Endpoint:** `GET /oauth2/get-consent`

### Client Side

```ts
const { data, error } = await authClient.oauth2.getConsent({
    query: {
        id, // required, The consent id
    },
});
```

### Server Side

```ts
const data = await auth.api.getOAuthConsent({
    query: {
        id, // required, The consent id
    },
    // This endpoint requires session cookies.
    headers: await headers(),
});
```

### Type Definition

```ts
type getOAuthConsent = {
  /**
   * The consent id
   */
  id: string,
}
```

#### List Consent [#list-consent]

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

**Endpoint:** `GET /oauth2/get-consents`

### Client Side

```ts
const { data, error } = await authClient.oauth2.getConsents();
```

### Server Side

```ts
const data = await auth.api.getOAuthConsents({
    // This endpoint requires session cookies.
    headers: await headers(),
});
```

### Type Definition

```ts
type getOAuthConsents = {
}
```

#### Update Consent [#update-consent]

To update a specific consent, use the following endpoint:

**Endpoint:** `POST /oauth2/update-consent`

### Client Side

```ts
const { data, error } = await authClient.oauth2.updateConsent({
    id, // required, The consent id
    update, // required, The values to update
});
```

### Server Side

```ts
const data = await auth.api.updateOAuthClient({
    body: {
        id, // required, The consent id
        update, // required, The values to update
    },
    // This endpoint requires session cookies.
    headers: await headers(),
});
```

### Type Definition

```ts
type updateOAuthClient = {
  /**
   * The consent id
   */
  id: string,
  /**
   * The values to update
   */
  update: OAuthConsent,
}
```

#### Delete Consent [#delete-consent]

Revokes a user's consent for a specific client.

**Endpoint:** `POST /oauth2/delete-consent`

### Client Side

```ts
const { data, error } = await authClient.oauth2.deleteConsent({
    id, // required, The consent id
});
```

### Server Side

```ts
const data = await auth.api.deleteOAuthConsent({
    body: {
        id, // required, The consent id
    },
    // This endpoint requires session cookies.
    headers: await headers(),
});
```

### Type Definition

```ts
type deleteOAuthConsent = {
  /**
   * The consent id
   */
  id: string,
}
```

### Dynamic Registration Endpoint [#dynamic-registration-endpoint]

<Callout type="info">
  This endpoint supports [RFC7591](https://datatracker.ietf.org/doc/html/rfc7591) compliant client registration.
</Callout>

Once installed, you can utilize the OAuth Provider to manage authentication flows within your application.

After a confidential client is created, you will receive a `client_id` and `client_secret` that you can display to the user. The `client_secret` can only be provided once, ensure the user saves it. Public clients receive only a `client_id`.

#### Setup [#setup]

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

```ts title="auth.ts"
oauthProvider({
  allowDynamicClientRegistration: true,
  // ... other options
})
```

To enable open client registration without a Better Auth session, additionally set `allowUnauthenticatedClientRegistration: true` in your auth config. Public clients are registered with `token_endpoint_auth_method: "none"`. Confidential clients receive a one-time `client_secret` in the registration response.

<Callout type="info">
  For MCP public-client identity, use [Client ID Metadata Documents](/docs/plugins/cimd). The [MCP 2026-07-28 spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration) deprecates Dynamic Client Registration in favor of CIMD; DCR remains supported for backwards compatibility.
</Callout>

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

#### Basic Example [#basic-example]

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

```ts
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](https://datatracker.ietf.org/doc/html/rfc7591#section-2).

`application_type` ([OIDC Registration §2](https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata)) defaults to `web` when omitted. A `web` client requires `https` redirect URIs on non-loopback hosts. A `native` client may use a claimed `https` URI, an `http` loopback URI on exactly `localhost`, `127.0.0.1`, or `[::1]` with any port, or an authority-free private-use URI with a reverse-domain scheme such as `com.example.app:/callback` ([RFC 8252 §7](https://datatracker.ietf.org/doc/html/rfc8252#section-7)). Loopback host matching uses the raw URI authority, so alternative numeric IPv4 spellings such as `127.1` are rejected before URL normalization. Better Auth also rejects credentials, fragments, malformed or reserved schemes such as `file:` and `mailto:`, loopback `https`, and routable-host `http` redirects.

Application type is independent of client authentication. For example, a native client may use `client_secret_post`, while a web client may use `token_endpoint_auth_method: "none"`. Authentication capability is derived only from `token_endpoint_auth_method`; the removed `type` and `public` metadata fields are not accepted or returned.

The [MCP 2026-07-28 spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#application-type-and-redirect-uri-constraints) requires MCP clients to send an appropriate `application_type`. A [Client ID Metadata Document](/docs/plugins/cimd) may omit it; Better Auth stores that omission as `null` and validates redirects against the safe union of web and native forms.

Note the following parameters are not yet supported:

* `sector_identifier_uri`

### Authorize Endpoint [#authorize-endpoint]

An [OAuth 2.1 authorization endpoint](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#name-authorization-endpoint). Since many of the details are not yet fully described, parts are adapted from the legacy [OAuth 2.0 Authorization Endpoint Section](https://datatracker.ietf.org/doc/html/rfc6749#section-3.1) but always implements the [differences from OAuth 2.0](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#name-differences-from-oauth-20).

The Authorization Endpoint is the entry point for initiating an OAuth 2.1 authorization flow.

Important notes:

* In OAuth 2.1, only `response_type: "code"` is supported.
* `code_challenge_method: "plain"` will not be supported since this is a security vulnerability.
* All authorization responses (success and error) include the `iss` parameter for issuer validation ([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207)).
* Use the `resource` indicators to restrict tokens to a resource ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html)).

**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)](https://datatracker.ietf.org/doc/html/rfc7636) 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](#token-endpoint) to obtain the tokens.

### Token Endpoint [#token-endpoint]

By default, the token endpoint supports providing tokens for the following grants:

* "authorization\_code"
* "client\_credentials"
* "refresh\_token"

#### Client Authentication Methods [#client-authentication-methods]

The token endpoint supports the following client authentication methods:

* **`client_secret_basic`** — Client credentials sent via HTTP Basic Auth header (default)
* **`client_secret_post`** — Client credentials sent in the request body
* **`private_key_jwt`** — Client authenticates with a signed JWT assertion ([RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523))
* **`none`** — Public client (PKCE required)

Important Notes:

* With the JWT plugin enabled (default), sending `resource` results in a JWT access token with the selected resource in the `aud` claim.
* With `disableJwtPlugin: true`, access tokens remain opaque. Requested resources are still bound to the token and surfaced through `/oauth2/introspect` and `customAccessTokenClaims` (via `resources`).

Token endpoint errors follow the OAuth taxonomy: missing required request fields return `invalid_request`, failed client authentication returns `invalid_client`, and invalid or mismatched grants return `invalid_grant`. A confidential client must use its registered `token_endpoint_auth_method`. Failed `client_secret_post` authentication returns `400`; failed `client_secret_basic` authentication returns `401` with a Basic `WWW-Authenticate` challenge.

#### DPoP sender-constrained tokens [#dpop-sender-constrained-tokens]

Better Auth supports Demonstrating Proof of Possession (DPoP) as defined by [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449). A client can ask for DPoP-bound tokens by registering with `dpop_bound_access_tokens: true`, by sending `dpop_jkt` on the authorization request, or by requesting a resource configured with `dpopBoundAccessTokensRequired: true`.

When a token is DPoP-bound:

* The token endpoint requires a valid `DPoP` proof JWT.
* The token response returns `token_type: "DPoP"`.
* JWT access tokens include `cnf.jkt`; opaque access tokens and refresh tokens persist the same JWK thumbprint.
* Refresh token rotation requires a DPoP proof from the same key.
* Resource requests must use `Authorization: DPoP <access_token>` and a `DPoP` proof containing the access-token hash (`ath`).

```ts title="auth.ts"
oauthProvider({
  dpop: {
    proofMaxAgeSeconds: 300,
    signingAlgorithms: ["ES256", "EdDSA"],
  },
  resources: [
    {
      identifier: "https://api.example.com",
      dpopBoundAccessTokensRequired: true,
    },
  ],
})
```

The authorization-server metadata advertises `dpop_signing_alg_values_supported`. Resource metadata advertises the same proof algorithms and, when required, `dpop_bound_access_tokens_required`.

#### Private Key JWT Authentication [#private-key-jwt-authentication]

With `private_key_jwt`, clients authenticate by signing a JWT with their private key instead of using a shared secret. The server verifies the signature using the client's registered public key (JWKS).

To register a `private_key_jwt` client, provide the client's public keys via `jwks` or `jwks_uri`:

```ts
const response = await auth.api.adminCreateOAuthClient({
  headers,
  body: {
    redirect_uris: ["https://app.example.com/callback"],
    token_endpoint_auth_method: "private_key_jwt",
    jwks: {
      keys: [
        {
          kty: "RSA",
          kid: "my-key-1",
          alg: "RS256",
          use: "sig",
          n: "...",
          e: "...",
        },
      ],
    },
  },
});
```

<Callout type="info">
  `jwks` and `jwks_uri` are general OIDC client key metadata and are mutually exclusive. Inline `jwks` must be an RFC 7517 JWK Set object with a non-empty `keys` array containing only public asymmetric signing keys; a bare key array is rejected. EC keys must use P-256, P-384, or P-521; OKP keys must use Ed25519. A key may omit `alg`. When present, `alg` must be a supported `private_key_jwt` algorithm that matches the key type and curve. When using `jwks_uri`, it must be an HTTPS URL pointing to a public (non-private) host and must return the same JWK Set object shape.
</Callout>

When exchanging tokens, the client sends a `client_assertion` JWT instead of a `client_secret`:

```
POST /api/auth/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=AUTH_CODE
&redirect_uri=https://app.example.com/callback
&client_id=CLIENT_ID
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJhbGciOiJSUzI1NiIs...
```

The assertion JWT must contain:

| Claim | Requirement                                                                                                                                                        |
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `iss` | Must be the `client_id`                                                                                                                                            |
| `sub` | Must be the `client_id`                                                                                                                                            |
| `aud` | Must contain either the URL of the endpoint receiving the assertion or the OpenID Provider issuer. Use a string or an array containing at least one accepted value |
| `exp` | Required, must not exceed `assertionMaxLifetime` from now                                                                                                          |
| `jti` | Required, must be unique (single-use)                                                                                                                              |
| `iat` | Optional, but if present must not be older than `assertionMaxLifetime`                                                                                             |

Supported signing algorithms: `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `ES256`, `ES384`, `ES512`, `EdDSA`.

#### Authorization code grant [#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 [#client-credentials-grant]

The client credentials grant enables clients to obtain machines to obtain access tokens.

#### Refresh token grant [#refresh-token-grant]

The refresh token grant enables clients to update their access token without needing the user to login again.

This implementation currently issues a new refresh token for every refresh request.

Set `refreshTokenReuseInterval` to allow a rotated refresh token to be reused for a short interval and receive the same token response. This lets clients recover from duplicate refresh requests, lost responses, or retries without minting another token pair.

```ts title="auth.ts"
oauthProvider({
  refreshTokenReuseInterval: 30, // seconds
})
```

The default is `0`, which keeps strict replay detection. During the interval, Better Auth replays the cached response only when the reused refresh token is from the same client and the request resolves to the same effective scopes, requested resources, and sender constraint (for example the same DPoP key). A mismatch during the interval returns `invalid_grant` without invalidating the whole family; once the interval expires, using the old refresh token is treated as replay and invalidates the refresh-token family.

The cached response is stored encrypted on the consumed refresh-token row and includes the replacement refresh token. `expires_in` is recalculated from the cached `expires_at` each time the response is replayed.

#### Device code grant [#device-code-grant]

The device authorization grant ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)) lets limited-input clients (CLIs, smart TVs, IoT) obtain an OAuth access token. Add `oauthDeviceAuthorization()` alongside `oauthProvider()` to register the `urn:ietf:params:oauth:grant-type:device_code` token grant and advertise `device_authorization_endpoint`. The integration adds the OAuth client binding and RFC 8707 resource fields; standalone Device Authorization installations remain unchanged. The device requests codes at `/device/code`, the user approves them, and the client polls `/oauth2/token` for a first-class OAuth token. See [Authorize a CLI to call an API](/docs/plugins/device-authorization#authorize-a-cli-to-call-an-api).

At `/device/code`, confidential clients authenticate with their registered method. A client using `client_secret_basic` may send an `Authorization: Basic ...` header and omit the body `client_id`; a client using `client_secret_post` sends `client_id` and `client_secret`; and a public client using `none` sends `client_id`. Empty `client_id`, `scope`, `user_id`, and authentication parameters are treated as omitted regardless of order. Repeated non-empty client identification, base request, or authentication parameters and multiple authentication methods return `invalid_request`, while repeated `resource` parameters remain supported.

An unknown OAuth client ID is not silently handled as a standalone request. Standalone fallback is available only when `oauthDeviceAuthorization({ validateClient })` explicitly accepts that ID. Malformed `resource` input returns `invalid_target` only when `resource` is the failing extension field; if a base request field is also invalid, the response returns `invalid_request`.

### Consent Endpoint [#consent-endpoint]

Accept or deny user consent for a set of scopes. Note that when denying scopes, the consent cancels and pre-existing consent remains. To remove consent, delete that user's "oauthConsent" for that client.

**Endpoint:** `POST /oauth2/consent`

### Client Side

```ts
const { data, error } = await authClient.oauth2.consent({
    accept, // required, Accept or deny user consent for a set of scopes
    scope, // Space-separated list of accepted scopes. If not provided, the originally requested scopes are accepted.
    claims, // Accepted OIDC claims request object. If not provided, the originally requested claims are accepted.
});
```

### Server Side

```ts
const data = await auth.api.oauth2Consent({
    body: {
        accept, // required, Accept or deny user consent for a set of scopes
        scope, // Space-separated list of accepted scopes. If not provided, the originally requested scopes are accepted.
        claims, // Accepted OIDC claims request object. If not provided, the originally requested claims are accepted.
    },
    // This endpoint requires session cookies.
    headers: await headers(),
});
```

### Type Definition

```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,
  /**
   * Accepted OIDC claims request object. If not provided, the originally requested claims are accepted.
   */
  claims?: string | Record<string, unknown>,
}
```

### Continue Endpoint [#continue-endpoint]

Sign up registration pages must be [configured](#sign-up-account-screen) to perform account registration steps.
Account selection must be [configured](#select-account-screen) to perform account selection.
Post login must be [configured](#post-login-screen) to perform post login selection.

**Endpoint:** `POST /oauth2/continue`

### Client Side

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

### Server Side

```ts
const data = await auth.api.oauth2Continue({
    body: {
        selected, // Confirms an account was selected.
        created, // Confirms an account was registered
        postLogin, // Confirms completion of post login activity
    },
    // This endpoint requires session cookies.
    headers: await headers(),
});
```

### Type Definition

```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,
}
```

### Introspect Endpoint [#introspect-endpoint]

[RFC7662](https://datatracker.ietf.org/doc/html/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`.

Use the `resources` field in `customAccessTokenClaims` to add claims based on the protected resource.

#### Who can introspect a token [#who-can-introspect-a-token]

The caller must authenticate as a registered client ([RFC 7662 §2.1](https://datatracker.ietf.org/doc/html/rfc7662#section-2.1)). It can then introspect a token in two cases:

* it issued the token, or
* it is a resource server linked to one of the token's resources.

The second case is the common one: a frontend client gets the token, and your API validates it. To set this up, register the API as a resource and link the client to it. Dynamic client registration with a `resources` field creates the link for you; otherwise add a row to the `oauthClientResource` table.

Any other authenticated client gets `{ active: false }`, the same response as an unknown or expired token, so introspection can't be used to fish for valid tokens. Refresh tokens are stricter: only the client that requested one can introspect it.

#### Which claims come back [#which-claims-come-back]

Opaque and JWT access tokens return the same claims for the same grant: your `customAccessTokenClaims` and any per-resource `customClaims`. The server owns the reserved claim names (`iss`, `sub`, `aud`, `scope`, `auth_time`, and similar). If a callback returns one of those, it is dropped rather than allowed to overwrite the server's value.

The two formats differ in one way. An opaque token is recomputed on each call, so introspection shows its current state: change a resource's claims, and the next introspection reflects the change. A JWT carries what was signed at issuance and never changes. Request a JWT (by passing a `resource`) when you want claims frozen at issuance; use an opaque token when you want the current state.

### Revoke Endpoint [#revoke-endpoint]

[RFC7009](https://datatracker.ietf.org/doc/html/rfc7009)-compliant Revocation.

What the endpoint does depends on the token type:

* opaque `access_token`: deleted from the database immediately. A `refresh_token` from the same grant stays valid.
* `refresh_token`: deletes every `access_token` it minted and removes the `refresh_token`, so it can no longer issue tokens.
* JWT `access_token`: cannot be revoked server-side. A JWT is self-contained and is never stored, so there is nothing to delete. A token that still verifies for this server responds with `400 unsupported_token_type` ([RFC7009 §2.2.1](https://datatracker.ietf.org/doc/html/rfc7009#section-2.2.1)), making clear that no server-side revocation happened. A JWT that is already expired or carries an audience rejected by the OAuth resource model is treated as an invalid token and returns a `200` no-op.

Because a JWT `access_token` cannot be revoked individually, plan for it:

* Keep its lifetime short. Use `accessTokenExpiresIn`, and `m2mAccessTokenExpiresIn` for `client_credentials` tokens.
* To cut a user off mid-session, end the session (sign-out, admin revoke, or back-channel logout). A JWT `access_token` that carries a session id (`sid`) is reported `active: false` by `/oauth2/introspect` and rejected by `/oauth2/userinfo` once that session ends, even before the token expires.
* A `client_credentials` JWT `access_token` has no session to end, so a short expiry is the only control available.

### End Session Endpoint [#end-session-endpoint]

[OpenID Connect RP-Initiated Logout 1.0](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) lets a relying party ask the provider to end a user's session.

The endpoint is available to clients registered with `enable_end_session: true`. It accepts logout parameters in a `GET` query or an `application/x-www-form-urlencoded` `POST` body. Better Auth's generated client sends the same parameters as JSON. A verified `id_token_hint` can end its referenced session immediately. Without a valid hint, Better Auth asks the user to confirm before ending the current session. The same confirmation is required when the hint refers to a different session than the browser session.

Better Auth redirects after logout only when `post_logout_redirect_uri` exactly matches a registered URI. It adds `state` only to that verified redirect. An unregistered or query-modified URI does not cause a redirect. Browser navigation receives confirmation, success, and error pages as HTML. API callers receive the protocol response.

Enable this endpoint only for clients you trust:

```ts title="admin-create-oauth.ts"
import { auth } from "@/lib/auth"

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

Better Auth protects confirmation with origin and CSRF checks plus a short-lived, signed cookie. When a current session exists, the cookie is tied to it. Successful browser logout returns a logged-out page. Session deletion continues through the normal hooks, including Back-Channel Logout delivery to registered relying parties.

### Back-Channel Logout [#back-channel-logout]

[Back-Channel Logout](https://openid.net/specs/openid-connect-backchannel-1_0.html) is the server-to-server counterpart to RP-Initiated Logout: when a user's session ends at the OP (sign-out, `/oauth2/end-session`, admin revoke, etc.), the OP POSTs a signed Logout Token to each registered Relying Party so they can terminate their own session state and revoke bound API access.

To opt a client in, register a `backchannel_logout_uri`:

```ts title="admin-create-oauth.ts"
await auth.api.adminCreateOAuthClient({
  headers,
  body: {
    redirect_uris: [redirectUri],
    enable_end_session: true,
    backchannel_logout_uri: "https://rp.example.com/logout/backchannel", // [!code highlight]
    backchannel_logout_session_required: true, // [!code highlight]
  }
});
```

When `backchannel_logout_session_required` is `true`, the RP requires a `sid` claim in every Logout Token. Every Logout Token the OP sends already includes `sid`, so such clients are always served.

The `backchannel_logout_uri` is validated at registration. Every client must use an absolute, credential-free, public `https` URL with no fragment; loopback and private targets are rejected even for local development. Hosts that are reserved, tunneled (NAT64, 6to4, IPv4-mapped IPv6), or cloud-metadata names are also rejected. This host check is syntactic: it does not resolve DNS, so pin or re-check the resolved address if you need protection against DNS rebinding. The same host check guards a client's `jwks_uri`, which separately requires `https` unconditionally and a trusted origin. A URI that fails validation is rejected with `invalid_client_metadata`. CIMD documents cannot register back-channel logout because the discovery transport is a GET-only trust boundary.

The OP enumerates clients with active tokens bound to the ending session and POSTs one `logout_token` to each in parallel (5s per-RP timeout, no retry per spec §2.5). It then revokes the session's tokens:

* **Refresh tokens** without `offline_access` are revoked; those with `offline_access` are preserved so long-lived API access can survive the browser session (spec §2.7).
* **Access tokens** bound to the session are revoked as additional hardening; §2.7 itself only addresses refresh tokens. Introspection and `/oauth2/userinfo` also treat any token whose session has ended as inactive, so this no longer depends on the stored flag alone.

The Logout Token carries the §2.4 claims (`iss`, `aud`, `iat`, `exp`, `jti`, `events`, plus `sub` and `sid`) with `typ: logout+jwt` in the protected header and no `nonce`. Its lifetime is capped at 120 seconds, following the §4 security guidance to keep replay windows short. It is signed with the same key as ID Tokens, so any RP that validates ID Tokens through your JWKS can validate Logout Tokens without extra configuration.

<Callout type="warn">
  Back-channel logout requires the `jwt` plugin. Registering a `backchannel_logout_uri` while `disableJwtPlugin: true` is rejected with `invalid_client_metadata`.
</Callout>

<Callout type="info">
  Delivery runs through `advanced.backgroundTasks.handler` when one is configured (Vercel `waitUntil`, Cloudflare `ctx.waitUntil`), so a slow RP cannot delay sign-out. Without a handler it completes inline before the sign-out response returns: reliable on persistent servers, but it can add latency when an RP is slow. Configure a handler on serverless runtimes.
</Callout>

<Callout type="info">
  The OP advertises `backchannel_logout_supported: true` and `backchannel_logout_session_supported: true` on both `.well-known/openid-configuration` and `.well-known/oauth-authorization-server`. RPs use these fields during dynamic client registration to decide whether to register a `backchannel_logout_uri`.
</Callout>

### UserInfo Endpoint [#userinfo-endpoint]

The UserInfo Endpoint provides [OIDC](https://openid.net/specs/openid-connect-core-1_0.html)-compliant user information. Available at `/oauth2/userinfo`, the endpoint requires a valid access token with at least the scope `openid`. An access token that is expired, revoked, or bound to a session that has ended is rejected with `invalid_token` (401), per [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1).

```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 endpoint also honors the OIDC `claims` request parameter for UserInfo. Claims listed under `claims.userinfo` are added to the scope-requested claims when Better Auth or your custom claim logic can supply them, bounded to the names advertised in `claims_supported`. Missing values are omitted from the JSON response rather than returned as `null` or an empty string.

Per-claim selection through `claims.userinfo` applies to opaque access tokens. A JWT access token resolves UserInfo claims from its granted scopes, so an individually requested claim without its backing scope is omitted (OIDC Core §5.5.1). Request the backing scope, or issue an opaque access token, when a client needs a claim that no granted scope covers.

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

### Well-Known [#well-known]

#### OpenID Configuration [#openid-configuration]

Provides [OpenID Connect discovery metadata](https://openid.net/specs/openid-connect-discovery-1_0.html) 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:

```ts title="[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);
```

<Callout type="info">
  If you get a CORS issue when testing locally such as with the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/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.
</Callout>

#### OAuth Authorization Server [#oauth-authorization-server]

Provides [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414)-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:

```ts title="/.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);
```

<Callout type="info">
  If you get a CORS issue when testing locally such as with the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/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.
</Callout>

## API Server [#api-server]

This section shows how your API should verify tokens received from your clients.

### Verification [#verification]

Verification can be performed using `verifyAccessTokenRequest` available through the `oauthProviderResourceClient` plugin or `better-auth/oauth2` package. This is the recommended resource-server API because it verifies the access token and, when the token is DPoP-bound, also verifies the request method, URL, `Authorization: DPoP` scheme, proof key, replay `jti`, and `ath` claim.

With `better-auth` package:

```ts title="api/[endpoint].ts"
import {
  requestToResourceInput,
  verifyAccessTokenRequest,
} from "better-auth/oauth2";

export const GET = async (req: Request) => {
  const payload = await verifyAccessTokenRequest(requestToResourceInput(req), {
    verifyOptions: {
      issuer: "https://auth.example.com",
      audience: "https://api.example.com",
    },
    requiredScopes: ["read:post"], // optional
  });
  // ...continue
}
```

`requestToResourceInput` reads the `Authorization` and `DPoP` headers and the method and URL from a standard `Request`. Pass a plain object with those fields if your framework does not expose a `Request`.

With `oauthProviderResourceClient` plugin:

```ts title="api/[endpoint].ts"
import { serverClient } from "@/lib/server-client";

export const POST = async (req: Request) => {
  const payload = await serverClient.verifyAccessTokenRequest(
    req,
    {
      verifyOptions: {
        issuer: "https://auth.example.com",
        audience: "https://api.example.com",
      },
      requiredScopes: ["write:post"], // optional
    }
  );
  // ...continue
}
```

`verifyBearerToken` is still available when you already extracted a raw bearer token and intentionally do not accept DPoP-bound tokens on that path. It rejects DPoP-bound tokens, so prefer `verifyAccessTokenRequest` for any endpoint that may receive them.

<Callout type="warn">
  DPoP verification compares the proof's `htu` against the request URL, and rejects replayed proofs through a `jti` store. Two deployment details matter:

  * **Behind a proxy:** the proof is checked against `request.url`, so a TLS-terminating or path-rewriting proxy must forward the externally visible scheme, host, and path, or legitimate proofs are rejected.
  * **Replay protection:** `verifyAccessTokenRequest` defaults to an in-memory `jti` store that is safe only for a single instance. For multi-instance or serverless resource servers, pass a shared `dpop.replayStore` such as `createDpopReplayStore(ctx.context.internalAdapter)`, which records proofs in the database-backed verification store (the provider's own endpoints and `requireMcpAuth` use it by default). It requires database-backed verification storage; a secondary-storage-only deployment rejects DPoP requests rather than skipping replay protection.
</Callout>

#### JWT Verification [#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 [#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 [#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-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 [#configuration]

### Redirect Screens [#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 custom page query parameters, they may coexist in the URL, but they should not be added to the signed `oauth_query`. The client plugin forwards only the parameters declared by the signed redirect.

If you utilize the Client Plugin `oauthProviderClient`, then the `oauth_query` parameter is automatically sent to every endpoint that requires it. If you have custom sign-in endpoints, you would need to manually add the window's signed query in the request body `oauth_query`. This should only include the signed query parameters.

#### Login Screen [#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.

```ts title="auth.ts"
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.

#### Consent Screen [#consent-screen]

When a user is redirected to the OIDC provider for authentication, they may be prompted to authorize the application to access their data.

**Note**: Trusted clients with `skip_consent: true` will bypass the consent screen entirely, providing a seamless experience for first-party applications.

```ts title="auth.ts"
oauthProvider({
  consentPage: "/consent" // [!code highlight]
})
```

The plugin will redirect the user to the specified path with `client_id`, `scope`, and, when requested, `claims` query parameters. Use `scope` and `claims.userinfo` to display the complete access request on your consent screen. Once the user consents, you can call `oauth2.consent` to complete the authorization.

```ts title="consent-page.ts"
import { authClient } from "@/lib/auth-client"

const claims = new URLSearchParams(window.location.search).get("claims");
const requestedClaims = claims ? JSON.parse(claims) : undefined;

const res = await authClient.oauth2.consent({
	accept: true,
  // optional scopes accepted (if not sent, accepted scopes matches the original request)
  scope: "openid profile email",
  // optional claims accepted (if not sent, accepted claims match the original request)
  claims: requestedClaims
});
```

#### Sign Up Account Screen [#sign-up-account-screen]

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

```ts title="auth.ts"
oauthProvider({
  signUp: {
    page: "/sign-up", // [!code highlight]
  }
})
```

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

```ts title="auth.ts"
import { userRegistered } from "@lib/registered";

oauthProvider({
  signUp: {
    page: "/sign-up",
    shouldRedirect: async ({ headers }) => { // [!code highlight]
      const isUserRegistered = await userRegistered(headers);
      return isUserRegistered ? false : "/setup";
    },
  }
})
```

#### Select Account Screen [#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:

```ts title="auth.ts"
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`.

```ts title="select-account.ts"
import { authClient } from "@/lib/auth-client"

await authClient.multiSession.setActive({
  sessionToken,
});
await client.oauth2.oauth2Continue({
  selected: true,
});
```

#### Post Login Screen [#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.

```ts title="auth.ts"
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`.

```ts title="select-organization.ts"
import { authClient } from "@/lib/auth-client"

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

### Cached Trusted Clients [#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.

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

### Resources [#resources]

A list of protected resources this OAuth server issues access tokens for. Each identifier is the RFC 8707 `resource` parameter value and becomes the JWT `aud` claim when a JWT access token is issued.

```ts title="auth.ts"
oauthProvider({
  resources: [
    "https://api.example.com",
    {
      identifier: "https://api.example.com/mcp",
      allowedScopes: ["mcp:read", "mcp:write"],
      accessTokenTtl: 300,
    },
  ]
})
```

Use the admin resource endpoints when resource policy needs to change at runtime.

Dynamic registration can attach protected resources to the new client in the same transaction as the client row. `clientRegistrationDefaultResources` adds server-owned defaults to every registration. `clientRegistrationAllowedResources` lists additional resources a client may request; the effective allowlist is the union of the default and allowed lists. Defaults appear first, duplicates are removed, and every configured value must also be present in `resources`.

```ts title="auth.ts"
oauthProvider({
  resources: [
    "https://api.example.com/default",
    "https://api.example.com/optional",
  ],
  clientRegistrationDefaultResources: [
    "https://api.example.com/default",
  ],
  clientRegistrationAllowedResources: [
    "https://api.example.com/optional",
  ],
})
```

Explicit resource requests are closed by default: when both registration-resource options are omitted, no resource may be requested. A requested resource outside the effective allowlist is rejected with `invalid_target`; missing and disabled resources are rejected too. Registrations with resolved resources return the final `resources` list and create the corresponding `oauthClientResource` links atomically.

### Scopes [#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 from UserInfo
* `email`: Returns email and email\_verified from UserInfo
* `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.

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

### Claims [#claims]

Internally supported claims include \["sub", "iss", "aud", "exp", "iat", "sid", "scope", "azp"].

ID token and UserInfo claims should be namespaced when possible to avoid potential future conflicts. In the authorization code flow, `profile`, `email`, and `claims.userinfo` request UserInfo claims; they are not added to the ID token unless you add them with `customIdTokenClaims`.

`customIdTokenClaims` is additive for protocol-owned ID token claims. Reserved names such as `iss`, `sub`, `aud`, token lifetime claims, `nonce`, `sid`, hash claims, `auth_time`, `acr`, `amr`, and `azp` are stripped at issuance with a warning log. Use namespaced claim names such as `https://example.com/org` for application-specific data.

Claims added inside `customIdTokenClaims` and `customUserInfoClaims` should be added to the `advertisedMetadata.claims_supported` so clients can validate that claim received. In the following example, it would be the base claims plus `locale` and `https://example.com/org`.

Pro tip: these functions can may also throw errors such as a user is no longer a member of the organization or no longer has the requested permissions.

```ts title="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, resources, metadata }) => {
    return {
      "https://example.com/org": referenceId,
      "https://example.com/resources": resources,
      "https://example.com/roles": ["editor"],
    };
  },
  // Additional user info claims
  customUserInfoClaims: ({ user, scopes, requestedClaims, jwt }) => {
    return {
      locale: "en-GB",
      ...(requestedClaims.includes("website")
        ? { website: "https://example.com" }
        : {}),
    };
  },
})
```

#### Custom Token Response Fields [#custom-token-response-fields]

Unlike the claim callbacks above (which add data *inside* JWT payloads), `customTokenResponseFields` adds fields to the **token endpoint JSON response** alongside `access_token`, `token_type`, etc. Standard OAuth fields cannot be overridden.

```ts title="auth.ts"
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.

### Expirations [#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
* `refreshTokenReuseInterval` defaults 0 seconds
* `codeExpiresIn` defaults 10 minutes
* `assertionMaxLifetime` defaults 5 minutes — maximum allowed lifetime for `private_key_jwt` client assertions

Additionally, Access Tokens can set lower expirations based on scopes. This is useful for higher-privilege scopes that require shorter expiration times. The earliest expiration will take precedence. If not specified, the default will take place. Note: values should be lower than the defaults `accessTokenExpiresIn` and `m2mAccessTokenExpiresIn`.

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

### Registration [#registration]

#### Dynamic Client Registration [#dynamic-client-registration]

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

```ts title="auth.ts"
oauthProvider({
  allowDynamicClientRegistration: true, // [!code highlight]
})
```

Unauthenticated client registration additionally allows clients to register without an authorization header. Public clients are registered with `token_endpoint_auth_method: "none"`. Confidential clients receive a one-time `client_secret` in the registration response. For MCP auth support, the recommended approach is via the [CIMD plugin](/docs/plugins/cimd) which maintains the identity of public clients.

```ts title="auth.ts"
oauthProvider({
  allowDynamicClientRegistration: true,
  allowUnauthenticatedClientRegistration: true, // [!code highlight]
})
```

<Callout type="info">
  For MCP public-client identity, use the [CIMD plugin](/docs/plugins/cimd).
</Callout>

Protected dynamic client registration allows machine callers to register public or confidential clients without a Better Auth user session. Issue an RFC 7591 initial access token out of band, then validate the token from the `Authorization: Bearer <token>` header. Defining `validateInitialAccessToken` enables this path; while it is undefined, a Bearer token sent to the registration endpoint is rejected.

```ts title="auth.ts"
import { createHash, timingSafeEqual } from "node:crypto"

const digest = (value: string) => createHash("sha256").update(value).digest()

oauthProvider({
  allowDynamicClientRegistration: true,
  validateInitialAccessToken: async ({ initialAccessToken, clientMetadata }) => {
    // Compare in constant time; hashing both sides keeps the lengths equal.
    const expected = digest(process.env.CLIENT_REGISTRATION_TOKEN ?? "")
    if (!timingSafeEqual(digest(initialAccessToken), expected)) {
      return false
    }

    return {
      referenceId: "infra-provisioner",
    }
  },
})
```

Return an object with a `referenceId` to attach application ownership metadata to the created client, or return `false` to reject the token. Omitting `referenceId` creates an unowned client. The `clientMetadata` passed to the callback is the submitted request, self-asserted and not yet fully validated, so treat it as untrusted input.

Token issuance, expiration, and revocation stay in your application because RFC 7591 leaves initial access token lifecycle policy to the authorization server. This is separate from RFC 7592 registration management tokens.

<Callout type="info">
  With the [Bearer](/docs/plugins/bearer) plugin enabled, an `Authorization: Bearer` value that resolves to a valid user session is handled as that session, not as an initial access token.
</Callout>

#### Dynamic Client Registration Expiration [#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.

```ts title="auth.ts"
oauthProvider({
  allowDynamicClientRegistration: true,
  clientRegistrationClientSecretExpiration: "30d", // [!code highlight]
})
```

#### Dynamic Client Registration Scopes [#dynamic-client-registration-scopes]

Registration scope metadata describes the scopes a client is capable of requesting; it is not a user authorization grant. Better Auth validates a requested `scope` as a subset of the operator policy, then persists the complete operator-approved capability set so later authorization can step up without re-registering the client.

Set the baseline capability list with `clientRegistrationDefaultScopes`. All values must be defined in `scopes`.

```ts title="auth.ts"
oauthProvider({
  scopes: ["reader", "editor"],
  clientRegistrationDefaultScopes: ["reader"], // [!code highlight]
})
```

Add capabilities with `clientRegistrationAllowedScopes`. The effective set is the deterministic, deduplicated union of both lists. When both options are omitted, `scopes` is the effective set. A DCR or CIMD document may request a subset, but that subset does not permanently prevent a later operation-specific step-up.

```ts title="auth.ts"
oauthProvider({
  scopes: ["reader", "editor"],
  clientRegistrationDefaultScopes: ["reader"],
  clientRegistrationAllowedScopes: ["editor"], // [!code highlight]
})
```

### PKCE Configuration [#pkce-configuration]

PKCE (Proof Key for Code Exchange) is a security mechanism that prevents authorization code interception attacks. This plugin follows the OAuth 2.1 specification, which requires PKCE by default for all authorization code flows.

#### Default Behavior [#default-behavior]

By default, PKCE is required for all clients. This provides maximum security and follows OAuth 2.1 best practices.

**PKCE is always required for:**

* Clients using `token_endpoint_auth_method: "none"`
* Authorization requests with the `offline_access` scope, unless a confidential client has opted out of PKCE and the OIDC request includes both `openid` and `nonce`

#### Per-Client PKCE Configuration [#per-client-pkce-configuration]

Admin-created confidential clients can opt out of the PKCE requirement if needed for compatibility:

```ts title="admin-create-oauth.ts"
// Register a confidential client that doesn't support PKCE
const response = await auth.api.adminCreateOAuthClient({
  headers,
  body: {
    client_name: 'Legacy Backend Service',
    redirect_uris: ['https://app.example.com/callback'],
    token_endpoint_auth_method: 'client_secret_post',
    grant_types: ['authorization_code'],
    require_pkce: false, // Opt-out of PKCE requirement
  }
});
```

The `require_pkce` field:

* Defaults to `true` (PKCE required)
* Only applies to confidential clients
* Ignored for public clients (PKCE always required)
* Requires an OIDC request with both `openid` and `nonce` when `offline_access` is requested without PKCE

#### Dynamic Client Registration PKCE Configuration [#dynamic-client-registration-pkce-configuration]

Dynamic client registration does not accept `require_pkce` from client requests. To change the server-owned default for dynamically registered confidential clients, set `clientRegistrationRequirePKCE`.

```ts title="auth.ts"
oauthProvider({
  allowDynamicClientRegistration: true,
  clientRegistrationRequirePKCE: false,
})
```

This only applies to confidential clients created through dynamic client registration. Public clients still require PKCE. Confidential OIDC clients that request `offline_access` without PKCE must send both `openid` and `nonce`.

**When to use `require_pkce: false`:**

* Migrating from OAuth 2.0 with legacy confidential clients that don't support PKCE
* Backend-to-backend integrations where updating the client is not feasible
* Temporary compatibility during a phased migration

**Recommendation:** Keep PKCE enabled (default) whenever possible. PKCE provides defense-in-depth even for confidential clients.

#### Security Considerations [#security-considerations]

PKCE prevents authorization code interception attacks. Even for confidential clients with client\_secret authentication, PKCE provides additional security:

* **Defense in depth**: Multiple security layers
* **Protection against misconfiguration**: Accidental secret exposure
* **Future-proof**: Aligns with OAuth 2.1 best practices

Only disable PKCE for confidential clients when absolutely necessary for legacy compatibility.

### Unauthenticated client discovery [#unauthenticated-client-discovery]

Some clients (notably MCP clients) need to connect to your authorization server without being registered in advance. The OAuth Provider plugin supports this through two mechanisms:

* **`allowUnauthenticatedClientRegistration`**: lets anonymous callers hit `/oauth2/register` to create a client at request time. Confidential registrations receive a one-time `client_secret`; public registrations use `token_endpoint_auth_method: "none"`.
* **[`@better-auth/cimd`](/docs/plugins/cimd)**: an optional plugin that lets clients identify themselves by hosting a metadata document at an HTTPS URL. The URL itself becomes the `client_id`; the server fetches and validates the document. Generic discovery follows [Client ID Metadata Document draft-02](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-02), while the MCP 2026-07-28 profile explicitly pins draft-00 requirements.

### Provider extensions [#provider-extensions]

OAuth companion plugins can extend the provider without changing OAuth Provider core. Use `extendOAuthProvider()` from a plugin `init()` hook to add token grants, assertion-based client authentication methods, additive discovery metadata, token or UserInfo claims, and client-id discovery sources. The [`@better-auth/cimd`](/docs/plugins/cimd) plugin uses this same surface to contribute its URL-based client discovery. A discovery source provides a stable, globally unique `id` that is persisted as client provenance and can provide `fetchClientMetadataResource` for resources owned by that discovery, such as a CIMD client's `jwks_uri`. Changing the ID requires migrating owned client rows; removing the matching discovery makes those clients fail closed.

```ts title="custom-oauth-extension.ts"
import type { BetterAuthPlugin } from "better-auth";
import { extendOAuthProvider } from "@better-auth/oauth-provider";

export const customOAuthExtension = () =>
  ({
    id: "custom-oauth-extension",
    init(ctx) {
      extendOAuthProvider(ctx, {
        grants: {
          "urn:example:params:oauth:grant-type:custom": async ({
            provider,
          }) => {
            const { client } = await provider.authenticateClient();
            return provider.issueTokens({
              client,
              scopes: ["openid"],
              tokenResponse: {
                issued_token_type:
                  "urn:ietf:params:oauth:token-type:access_token",
              },
            });
          },
        },
        metadata: () => ({
          custom_grant_supported: true,
        }),
      });
    },
  }) satisfies BetterAuthPlugin;
```

Extension contributions follow two disciplines:

* **Dispatched kinds** (`grants`, `clientAuthentication`) must be disjoint across extensions. Registering a grant type, `token_endpoint_auth_method`, or `client_assertion_type` that another extension already registered is rejected at setup, so a contribution can never be silently shadowed. Extension grants and auth methods are advertised in discovery automatically.
* **Additive kinds** (`metadata`, `claims`) never override authorization-server core. A metadata field the provider already owns (`issuer`, `token_endpoint`, `grant_types_supported`, the authentication-method lists, ...) is kept, and a key two extensions both contribute resolves to the first-registered extension.

A claims contributor can add new claim names but never replaces an identity, authentication-context, reserved RFC 9068, or other provider-owned claim. To advertise the claim names an extension emits, set `advertisedMetadata.claims_supported`: the provider owns `claims_supported` and does not infer it from contributors.

#### Provider capabilities outside a grant [#provider-capabilities-outside-a-grant]

A grant handler receives a `provider` capability surface (`getClient`, `authenticateClient`, `issueTokens`, `hashToken`, `validateAccessToken`, `requireActiveAccessToken`). A plugin that needs those from its own endpoints (a back-channel authorization endpoint, a polling endpoint) obtains the same object with `getOAuthProviderApi(ctx, opts, grantType?)`, so it can resolve a client or verify a token without reaching into provider internals. Use `validateAccessToken` for introspection-style flows that can handle inactive payloads, and `requireActiveAccessToken` for protected-resource endpoints that should reject inactive or unknown tokens with an OAuth bearer challenge. Pass the grant type to mint tokens away from the token endpoint; omit it for read-only use, in which case `issueTokens` throws rather than mint an unlabeled grant.

To sender-constrain an issued token (RFC 7800 `cnf`), pass `confirmation` to `issueTokens`, or return it from a `clientAuthentication` strategy. The provider stamps it as the access token's `cnf` and marks the response `token_type` accordingly. `cnf` is authorization-server-owned and cannot be set through a claim contributor.

#### Client authentication obligations [#client-authentication-obligations]

A `clientAuthentication` strategy verifies the assertion against its own key source and returns the client id it proved; the provider resolves and authorizes the client record itself, so a strategy proves identity but never supplies the record. After verifying the signature it must enforce the same assertion hygiene the built-in `private_key_jwt` method enforces, or the provider will accept a forged or replayed assertion. Use the exported `consumeClientAssertion` helper to bind the assertion to the endpoint audience, require a bounded lifetime, and reject `jti` replays:

```ts
import { consumeClientAssertion } from "@better-auth/oauth-provider";

authenticate: async ({ ctx, opts, assertion, expectedAudience }) => {
  const payload = await verifyAssertionSignature(assertion); // your key source
  await consumeClientAssertion(ctx, opts, {
    // Scopes the replay tombstone; the same jti may recur across distinct
    // methods or clients but never within one.
    namespace: `urn:example:attestation:${payload.sub}`,
    payload,
    expectedAudience: expectedAudience!,
  });
  // Return only the proven client id (and an optional `confirmation`). The
  // provider resolves and authorizes the client record itself.
  return { clientId: payload.sub as string };
};
```

#### Claim precedence [#claim-precedence]

Three claim surfaces resolve contributions in a fixed order. Across all three, third-party extension claims are strictly additive, while the operator's own first-party callbacks may override profile-style identity claims; protocol-owned identity, lifetime, binding, and authentication-context claims are always pinned or reserved by the provider.

| Token        | Order (lowest to highest authority)                                                                                                   | Pinned or reserved by provider                                                                                                                                                                    |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Access token | extension `claims.accessToken` \< per-issuance `accessTokenClaims` \< `customAccessTokenClaims` \< per-resource `customClaims`        | reserved RFC 9068 names (`iss`, `sub`, `aud`, `exp`, `iat`, `jti`, `client_id`, `scope`, `auth_time`, `acr`, `amr`), stripped before signing                                                      |
| ID token     | subject/authentication claims \< `customIdTokenClaims`; extension and per-issuance `idTokenClaims` are reserved-filtered and additive | reserved OIDC/JWT names (`iss`, `sub`, `aud`, `exp`, `nbf`, `iat`, `jti`, `nonce`, `sid`, `at_hash`, `c_hash`, `s_hash`, `auth_time`, `acr`, `amr`, `azp`) and scope-derived UserInfo claim names |
| UserInfo     | scope and `claims.userinfo` identity claims \< extension `claims.userInfo` (additive only) \< `customUserInfoClaims`                  | `sub` (re-pinned last)                                                                                                                                                                            |

Per-issuance `accessTokenClaims` are JWT-only: an opaque access token persists no per-issuance claims, so they do not reappear at introspection. A claim that must be visible at opaque-token introspection belongs in a grant-type-stable `claims.accessToken` contributor, which the introspection path re-derives.

### Organizations [#organizations]

OAuth Clients are tied to either a user or `reference_id` at registration and is immutable. If you are utilizing the [organization plugin](/docs/plugins/organization), you must ensure that the [`activeOrganizationId`](/docs/plugins/organization#active-organization) is set on your active session when you create new clients.

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

To set user-specific permissions and roles on tokens see [Claims](#claims).

### Client CRUD Privileges [#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:

```ts title="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 [#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.

### Rate Limiting [#rate-limiting]

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:

```ts title="auth.ts"
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`:

```ts title="auth.ts"
oauthProvider({
  rateLimit: {
    introspect: false, // Uses global rate limits instead of per-endpoint limits
  },
})
```

<Callout type="info">
  Setting an endpoint to `false` removes the OAuth Provider's stricter per-endpoint limit. The endpoint will still be subject to Better Auth's global rate limiting if enabled.
</Callout>

### Refresh Token Customization [#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:

```ts title="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:

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

### Advertised Metadata [#advertised-metadata]

The metadata endpoint can be customized so that the publicized scopes and claims differ from those which the server can deliver. This can prevent showcasing all your supported scopes and claims on your metadata endpoint.

All scopes inside the advertisedMetadata section MUST be listed in `scopes` otherwise initialization will fail.

Better Auth advertises `acr_values_supported: ["0"]`. In OIDC Core, `"0"` means the authentication did not meet ISO/IEC 29115 level 1. Custom ACR policies are not currently supported. Because `acr_values` is voluntary, requests for other classes continue and the ID token reports `acr: "0"`. In an OpenID Connect request, an essential `claims.id_token.acr` request fails when its `value` or `values` does not include `"0"`.

#### Scopes [#scopes-1]

```ts title="auth.ts"
oauthProvider({
  scopes: ["openid", "profile", "email", "offline_access", "read:post"],
  advertisedMetadata: {
    scopes_supported: ["openid", "profile", "read:post"],
  },
})
```

#### Claims [#claims-1]

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

```ts title="auth.ts"
oauthProvider({
  advertisedMetadata: {
    claims_supported: ["https://example.com/roles"],
  },
})
```

### Disable JWT Plugin [#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`.

```ts title="auth.ts"
oauthProvider({
  disableJwtPlugin: true, // [!code highlight]
})
```

### Pairwise Subject Identifiers [#pairwise-subject-identifiers]

By default, the `sub` (subject) claim in tokens uses the user's internal ID, which is the same across all clients. This is the **public** subject type per [OIDC Core Section 8](https://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes).

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.

```ts title="auth.ts"
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.

#### Per-Client Configuration [#per-client-configuration]

```ts title="register-client.ts"
const response = await auth.api.createOAuthClient({
  headers,
  body: {
    client_name: 'Privacy-Sensitive App',
    redirect_uris: ['https://app.example.com/callback'],
    token_endpoint_auth_method: 'client_secret_post',
    subject_type: 'pairwise', // Enable pairwise sub for this client
  }
});
```

#### How It Works [#how-it-works]

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:

* Two clients with different redirect URI hosts always receive different `sub` values for the same user
* Two clients sharing the same redirect URI host receive the **same** pairwise `sub` (per OIDC Core Section 8.1)
* The same client always receives the same `sub` for the same user (deterministic)

Pairwise `sub` appears in:

* `id_token`
* `/oauth2/userinfo` response
* Token introspection (`/oauth2/introspect`)

When a resource server introspects a token issued to another client, it gets the `sub` that the issuing client sees, not one computed for the resource server itself. So a given user always appears under the same `sub` for that issuing client, whichever resource server asks.

JWT access tokens always use the real user ID as `sub`, since resource servers may need to look up users directly.

<Callout type="warn">
  **Limitations:**

  * `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.
  * Rotating `pairwiseSecret` will change all pairwise `sub` values, breaking existing RP sessions. Treat this secret as permanent once set.
</Callout>

### MCP [#mcp]

Use the [`@better-auth/mcp` plugin](/docs/plugins/mcp) when an MCP server is one of your protected resources. It builds on this OAuth Provider and adds MCP defaults, RFC 9728 protected resource metadata, and route helpers that return the authorization challenge MCP clients expect.

`mcp()` is the OAuth Provider for that Better Auth instance, so do not register both `mcp()` and `oauthProvider()`. It accepts the OAuth Provider options directly. Use `requireMcpAuth` when the MCP route shares the auth instance, or `createMcpProtectedRequestHandler` when the resource server runs separately.

The MCP plugin can also support a separate registered CLI through the device grant. MCP clients keep their discovery-driven authorization code flow, while the CLI asks the same provider for a resource-bound token through device authorization. See [Add device authorization for your own CLI](/docs/plugins/mcp#add-device-authorization-for-your-own-cli).

## Schema [#schema]

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

### OAuth Client [#oauth-client-1]

Table Name: `oauthClient`



<DatabaseTable name="oauthClient" fields="oauthClientTableFields" />

### OAuth Refresh Token [#oauth-refresh-token]

Table Name: `oauthRefreshToken`



<DatabaseTable name="oauthRefreshToken" fields="oauthRefreshTokenTableFields" />

### OAuth Access Token [#oauth-access-token]

Table Name: `oauthAccessToken`



<DatabaseTable name="oauthAccessToken" fields="oauthAccessTokenTableFields" />

### OAuth Consent [#oauth-consent-1]

Table Name: `oauthConsent`



<DatabaseTable name="oauthConsent" fields="oauthConsentTableFields" />

### OAuth Client Assertion [#oauth-client-assertion]

Table Name: `oauthClientAssertion`

Records each `private_key_jwt` client assertion `jti` so it can only be used once. The row id is a digest of the per-client assertion identifier, so a replayed or concurrent assertion collides on the primary key and the database rejects it atomically, even across multiple server processes. A row keeps blocking its id until deleted; `expiresAt` marks when removal is safe, because the assertion it guards has already expired. No scheduled job prunes these rows, so remove expired rows with your own cleanup if the table grows.



<DatabaseTable name="oauthClientAssertion" fields="oauthClientAssertionTableFields" />

## Options [#options]

### Prefix [#prefix]

Add a `prefix` to opaque access tokens, refresh tokens, or client secrets. This is useful for Secret Scanners (ie. [GitHub Secret Scanners](https://docs.github.com/code-security/secret-scanning), [GitGuardian](https://www.gitguardian.com/solutions/secrets-scanning), [Trufflehog](https://github.com/trufflesecurity/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 [#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.

