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

OAuth provider options, endpoints, errors, rate limits, extensions, and database schema.



## Plugin options [#plugin-options]

Pass these options to `oauthProvider()`. Only `loginPage` and `consentPage` are required.

### Sign-in flow [#sign-in-flow]

`loginPage` <span className="opacity-70">`string`</span>

Required. Page the authorize endpoint redirects to when the user must sign in. See [Login page](/docs/plugins/oauth-provider/sign-in-flow#login-page).

`consentPage` <span className="opacity-70">`string`</span>

Required. Page the authorize endpoint redirects to when the user must consent. See [Consent page](/docs/plugins/oauth-provider/sign-in-flow#consent-page).

`signup` <span className="opacity-70">`object`</span>

Settings for `prompt=create`. See [Sign-up account screen](/docs/plugins/oauth-provider/sign-in-flow#sign-up-account-screen).

`selectAccount` <span className="opacity-70">`object`</span>

Settings for `prompt=select_account`. See [Select account screen](/docs/plugins/oauth-provider/sign-in-flow#select-account-screen).

`postLogin` <span className="opacity-70">`object`</span>

A step between login and consent, such as choosing an organization. See [Post-login screen](/docs/plugins/oauth-provider/sign-in-flow#post-login-screen).

`allowPublicClientPrelogin` <span className="opacity-70">`boolean`</span>

Lets `/oauth2/public-client-prelogin` answer before sign-in when the request carries a valid signed `oauth_query`. The signed query does not limit which `client_id` is read, so every enabled client's public fields, including `contacts`, become readable without an account. Defaults to `false`.

<Accordions>
  <Accordion title="signup options">
    `page` <span className="opacity-70">`string`</span>

    Sign-up page. Defaults to `loginPage`. When sign-up finishes, call `/oauth2/continue` with `created: true`.

    `shouldRedirect` <span className="opacity-70">`(context) => Awaitable<boolean | string>`</span>

    Adds registration steps for a signed-in user. Return `true` to redirect to `page`, a string to redirect to that page, or `false` to continue the flow.
  </Accordion>

  <Accordion title="selectAccount options">
    `page` <span className="opacity-70">`string`</span>

    Account selection page. Without it, `prompt=select_account` returns an error, and a `true` from `shouldRedirect` sends the user to `loginPage`. After selection, call `/oauth2/continue` with `selected: true`.

    `shouldRedirect` <span className="opacity-70">`(context) => Awaitable<boolean>`</span>

    Required. Return `true` when the user still has to pick an account.
  </Accordion>

  <Accordion title="postLogin options">
    `page` <span className="opacity-70">`string`</span>

    Required. Page to redirect to when `shouldRedirect` returns `true`.

    `shouldRedirect` <span className="opacity-70">`(context) => Awaitable<boolean>`</span>

    Required. Return `true` while a selection is still needed. The context includes `isContinuation`, which is `true` when the user returns through `/oauth2/continue` with `postLogin: true`. The client chooses to send that request, so validate the session before returning `false`.

    `consentReferenceId` <span className="opacity-70">`(context) => Awaitable<string | undefined>`</span>

    Required. The reference that consent is stored under. `undefined` or `""` uses consent given without a reference. Throw here when the requested scopes need a reference and none is set.
  </Accordion>
</Accordions>

### Scopes and claims [#scopes-and-claims]

`scopes` <span className="opacity-70">`string[]`</span>

Scopes clients may request. Without `openid`, the provider is OAuth only. Defaults to `["openid", "profile", "email", "offline_access"]`. See [Scopes](/docs/plugins/oauth-provider/tokens#scopes).

`advertisedMetadata` <span className="opacity-70">`{ scopes_supported?, claims_supported? }`</span>

Overrides what discovery advertises. Every `scopes_supported` value must be in `scopes`. See [Discovery metadata](/docs/plugins/oauth-provider/openid-connect#discovery-metadata).

`customAccessTokenClaims` <span className="opacity-70">`(info) => Awaitable<Record<string, any>>`</span>

Adds access token claims, also returned by introspection. See [Custom claims](/docs/plugins/oauth-provider/tokens#custom-claims).

`customIdTokenClaims` <span className="opacity-70">`(info) => Awaitable<Record<string, any>>`</span>

Adds ID token claims. Reserved names are stripped. See [ID token claims](/docs/plugins/oauth-provider/openid-connect#id-token-claims).

`customUserInfoClaims` <span className="opacity-70">`(info) => Awaitable<Record<string, any>>`</span>

Adds UserInfo claims. See [UserInfo](/docs/plugins/oauth-provider/openid-connect#userinfo).

`customTokenResponseFields` <span className="opacity-70">`(info) => Awaitable<Record<string, unknown>>`</span>

Adds fields to the token response body. Standard fields cannot be overridden. See [Custom token response fields](/docs/plugins/oauth-provider/tokens#custom-token-response-fields).

`pairwiseSecret` <span className="opacity-70">`string`</span>

Enables pairwise `sub` values for clients with `subject_type: "pairwise"`. Must be at least 32 characters. See [Pairwise subject identifiers](/docs/plugins/oauth-provider/openid-connect#pairwise-subject-identifiers).

### Clients and registration [#clients-and-registration]

`grantTypes` <span className="opacity-70">`GrantType[]`</span>

Grants the token endpoint accepts. `refresh_token` requires `authorization_code`. Extension grants are added on top. Defaults to `["authorization_code", "client_credentials", "refresh_token"]`.

`validateRedirectUri` <span className="opacity-70">`(uri, registeredUris, defaultResult) => Awaitable<boolean>`</span>

Replaces exact redirect URI matching. See [Custom redirect URI validation](/docs/plugins/oauth-provider/clients#custom-redirect-uri-validation).

`cachedTrustedClients` <span className="opacity-70">`Set<string>`</span>

Client IDs to cache. Cached clients cannot be changed through the CRUD endpoints. See [Trusted clients](/docs/plugins/oauth-provider/clients#trusted-clients).

`clientPrivileges` <span className="opacity-70">`(context) => Awaitable<boolean | undefined>`</span>

Authorizes client actions: `create`, `read`, `update`, `delete`, `list`, `rotate`, and `configure-client-credentials-scopes`. See [Client CRUD privileges](/docs/plugins/oauth-provider/clients#client-crud-privileges).

`clientReference` <span className="opacity-70">`(context) => Awaitable<string | undefined>`</span>

Returns the owner reference, such as an organization ID, for new clients. See [Organizations](/docs/plugins/oauth-provider/clients#organizations).

`allowDynamicClientRegistration` <span className="opacity-70">`boolean`</span>

Enables `POST /oauth2/register`. Defaults to `false`. See [Dynamic client registration](/docs/plugins/oauth-provider/clients#dynamic-client-registration).

`allowUnauthenticatedClientRegistration` <span className="opacity-70">`boolean`</span>

Accepts registration requests without a session. Defaults to `false`. Anyone on the internet can then register a client; read [Dynamic client registration](/docs/plugins/oauth-provider/clients#dynamic-client-registration) before you enable it.

`validateInitialAccessToken` <span className="opacity-70">`(context) => Awaitable<{ referenceId? } | false>`</span>

Enables registration with an initial access token ([RFC 7591 §3](https://datatracker.ietf.org/doc/html/rfc7591#section-3)).

`clientRegistrationDefaultScopes` <span className="opacity-70">`string[]`</span>

Scopes stored on every dynamically registered client. Defaults to `scopes`, so registered clients get every scope your server defines. Narrow it when you enable open registration.

`clientRegistrationAllowedScopes` <span className="opacity-70">`string[]`</span>

Extra scopes a registration may ask for. Every value must be in `scopes`. Defaults to `[]`.

`clientRegistrationRequirePKCE` <span className="opacity-70">`boolean`</span>

Whether dynamically registered confidential clients require PKCE. Defaults to `true`. `false` removes the protection against intercepted and injected codes for every such client, including anonymous ones. See [PKCE](/docs/plugins/oauth-provider/tokens#pkce).

`clientRegistrationDefaultApplicationType` <span className="opacity-70">`"web" | "native" | "infer"`</span>

`application_type` used when a registration omits it. Defaults to `"web"`.

`clientRegistrationClientSecretExpiration` <span className="opacity-70">`number | string | Date`</span>

The `client_secret_expires_at` value returned to dynamically registered clients that receive a secret. A string such as `"30d"` is a duration from registration. A number (Unix seconds) or a `Date` is a fixed time. Better Auth does not reject the secret after that time. Defaults to no expiry.

`clientRegistrationDefaultResources` <span className="opacity-70">`string[]`</span>

Resources linked to every new client, including CIMD, anonymously registered, and admin-created clients. Each must be in `resources`. Every client that can register can then get tokens for these APIs.

`clientRegistrationAllowedResources` <span className="opacity-70">`string[]`</span>

Resources a registration may request. Each must be in `resources`. When both resource options are omitted, requested resources are rejected.

`assertionMaxLifetime` <span className="opacity-70">`number`</span>

Maximum lifetime in seconds of a `private_key_jwt` assertion. Defaults to `300`. See [Private key JWT](/docs/plugins/oauth-provider/clients#private-key-jwt).

`allowClientKeyRotation` <span className="opacity-70">`boolean`</span>

Enables `POST /oauth2/client/rotate-keys`. Startup fails on the memory adapter or with `advanced.database.generateId: "serial"`, which cannot keep assertions single-use. Defaults to `false`. See [Rotating client keys](/docs/plugins/oauth-provider/clients#rotating-client-keys).

### Resources [#resources]

`resources` <span className="opacity-70">`Array<string | OAuthResourceInput>`</span>

Protected resources seeded into `oauthResource` at startup. See [Resources](/docs/plugins/oauth-provider/tokens#resources).

`resourceSeedMode` <span className="opacity-70">`"insertOnly" | "merge" | "overwrite"`</span>

How seeding treats existing rows: skip them, update only the configured fields, or replace them. Defaults to `"insertOnly"`.

`enforcePerClientResources` <span className="opacity-70">`boolean`</span>

Requires a client to be linked to every `resource` it requests ([RFC 8707 §2](https://datatracker.ietf.org/doc/html/rfc8707#section-2)). Defaults to `true`. `false` lets any client, including self-registered ones, get tokens for every enabled resource.

`cachedResources` <span className="opacity-70">`Set<string>`</span>

Resource identifiers to cache. Writes through the admin endpoints invalidate the cache.

`identifierValidator` <span className="opacity-70">`(identifier) => Awaitable<boolean>`</span>

Validates identifiers created through the admin endpoints or registration. The default requires an absolute URI without a fragment ([RFC 8707 §2](https://datatracker.ietf.org/doc/html/rfc8707#section-2)).

`resourcePrivileges` <span className="opacity-70">`(context) => Awaitable<boolean | undefined>`</span>

Authorizes resource actions: `create`, `read`, `update`, `delete`, `list`, `link`, and `unlink`.

<Callout type="warn">
  Without `resourcePrivileges`, any signed-in user whose headers reach the admin resource endpoints can manage resources. Define it before calling those endpoints from a route.
</Callout>

### Tokens [#tokens]

`accessTokenExpiresIn` <span className="opacity-70">`number`</span>

Access token lifetime in seconds. Defaults to `3600`. See [Lifetimes](/docs/plugins/oauth-provider/tokens#lifetimes).

`m2mAccessTokenExpiresIn` <span className="opacity-70">`number`</span>

Lifetime in seconds of `client_credentials` access tokens. Defaults to `3600`.

`idTokenExpiresIn` <span className="opacity-70">`number`</span>

ID token lifetime in seconds. Defaults to `36000`.

`refreshTokenExpiresIn` <span className="opacity-70">`number`</span>

Refresh token lifetime in seconds. Defaults to `2592000`. Each refresh starts a new window, so a regularly used grant does not expire.

`codeExpiresIn` <span className="opacity-70">`number`</span>

Authorization code lifetime in seconds. Defaults to `600`.

`scopeExpirations` <span className="opacity-70">`Record<scope, number | string | Date>`</span>

Shorter access token lifetimes per scope. The earliest expiry wins.

`refreshTokenReuseInterval` <span className="opacity-70">`number`</span>

Seconds a rotated refresh token can replay the same response. Defaults to `0`. See [Refresh tokens](/docs/plugins/oauth-provider/tokens#refresh-tokens).

`formatRefreshToken` <span className="opacity-70">`{ encrypt, decrypt }`</span>

Custom refresh token encoding sent to clients.

`disableJwtPlugin` <span className="opacity-70">`boolean`</span>

Issues opaque access tokens and HS256 ID tokens. Defaults to `false`. See [Without the JWT plugin](/docs/plugins/oauth-provider/tokens#without-the-jwt-plugin).

`dpop` <span className="opacity-70">`{ proofMaxAgeSeconds?, signingAlgorithms? }`</span>

DPoP proof validation. `proofMaxAgeSeconds` defaults to `300`, and `signingAlgorithms` to `["EdDSA", "ES256", "ES512", "PS256", "RS256"]`. See [DPoP](/docs/plugins/oauth-provider/tokens#dpop).

`requestUriResolver` <span className="opacity-70">`(input) => Promise<Record<string, string> | null>`</span>

Resolves a `request_uri` at the authorize endpoint for pushed authorization requests ([RFC 9126 §4](https://datatracker.ietf.org/doc/html/rfc9126#section-4)). Return `null` for an unknown or expired URI.

### Storage and generation [#storage-and-generation]

`storeClientSecret` <span className="opacity-70">`"hashed" | "encrypted" | { hash, verify? } | { encrypt, decrypt }`</span>

How client secrets are stored. Hashing requires the JWT plugin; encryption requires `disableJwtPlugin`. Defaults to `"hashed"`, or `"encrypted"` with `disableJwtPlugin`. See [Storage](/docs/plugins/oauth-provider/tokens#storage).

`storeTokens` <span className="opacity-70">`"hashed" | { hash }`</span>

How opaque access tokens and refresh tokens are stored. Defaults to `"hashed"`.

`prefix` <span className="opacity-70">`{ opaqueAccessToken?, refreshToken?, clientSecret? }`</span>

Prefixes added to returned values and not stored. Set them before your first deployment; after that, add the prefix in the matching `generate*` function instead.

`generateClientId` <span className="opacity-70">`() => string`</span>

Generates client IDs. Defaults to 32 random letters.

`generateClientSecret` <span className="opacity-70">`() => string`</span>

Generates client secrets. Defaults to 32 random letters.

`generateOpaqueAccessToken` <span className="opacity-70">`() => Awaitable<string>`</span>

Generates opaque access tokens. Defaults to 32 random letters.

`generateRefreshToken` <span className="opacity-70">`() => Awaitable<string>`</span>

Generates refresh tokens. Defaults to 32 random letters.

### Other [#other]

`rateLimit` <span className="opacity-70">`object`</span>

Per-endpoint rate limits. For the defaults, see [Rate limits](#rate-limits).

`extensions` <span className="opacity-70">`OAuthProviderExtension[]`</span>

Companion extensions. Plugins usually call `extendOAuthProvider()` instead. See [Provider extensions](#provider-extensions).

`schema` <span className="opacity-70">`object`</span>

Custom model and field names for the plugin tables.

## Endpoints [#endpoints]

Paths are relative to your auth base path. "Server-only" endpoints are callable through `auth.api` only.

| Method       | Path                                                     | Server method                  | Auth                                                   | Server-only |
| ------------ | -------------------------------------------------------- | ------------------------------ | ------------------------------------------------------ | ----------- |
| `GET`        | `/.well-known/oauth-authorization-server`                | `getOAuthServerConfig`         | None                                                   | No          |
| `GET`        | `/.well-known/openid-configuration`                      | `getOpenIdConfig`              | None                                                   | No          |
| `GET` `POST` | `/oauth2/authorize`                                      | `oauth2Authorize`              | Session, or redirect to `loginPage`                    | No          |
| `POST`       | `/oauth2/consent`                                        | `oauth2Consent`                | Session and signed `oauth_query`                       | No          |
| `POST`       | `/oauth2/continue`                                       | `oauth2Continue`               | Session and signed `oauth_query`                       | No          |
| `POST`       | `/oauth2/token`                                          | `oauth2Token`                  | Client authentication                                  | No          |
| `POST`       | `/oauth2/introspect`                                     | `oauth2Introspect`             | Client authentication                                  | No          |
| `POST`       | `/oauth2/revoke`                                         | `oauth2Revoke`                 | Client authentication                                  | No          |
| `GET` `POST` | `/oauth2/userinfo`                                       | `oauth2UserInfo`               | Access token with `openid`                             | No          |
| `GET` `POST` | `/oauth2/end-session`                                    | `oauth2EndSession`             | `id_token_hint`, or user confirmation                  | No          |
| `POST`       | `/oauth2/end-session/confirm`                            | —                              | Signed confirmation cookie                             | HTTP only   |
| `POST`       | `/oauth2/register`                                       | `registerOAuthClient`          | Session, initial access token, or none                 | No          |
| `POST`       | `/admin/oauth2/create-client`                            | `adminCreateOAuthClient`       | Server, with a session                                 | Yes         |
| `PATCH`      | `/admin/oauth2/update-client`                            | `adminUpdateOAuthClient`       | Server, with a session                                 | Yes         |
| `POST`       | `/oauth2/create-client`                                  | `createOAuthClient`            | Session                                                | No          |
| `GET`        | `/oauth2/get-client`                                     | `getOAuthClient`               | Session                                                | No          |
| `GET`        | `/oauth2/public-client`                                  | `getOAuthClientPublic`         | Session                                                | No          |
| `POST`       | `/oauth2/public-client-prelogin`                         | `getOAuthClientPublicPrelogin` | Signed `oauth_query`, with `allowPublicClientPrelogin` | No          |
| `GET`        | `/oauth2/get-clients`                                    | `getOAuthClients`              | Session                                                | No          |
| `POST`       | `/oauth2/update-client`                                  | `updateOAuthClient`            | Session                                                | No          |
| `POST`       | `/oauth2/client/rotate-secret`                           | `rotateClientSecret`           | Session                                                | No          |
| `POST`       | `/oauth2/client/rotate-keys`                             | `rotateClientKeys`             | Client assertion                                       | No          |
| `POST`       | `/oauth2/delete-client`                                  | `deleteOAuthClient`            | Session                                                | No          |
| `GET`        | `/oauth2/get-consent`                                    | `getOAuthConsent`              | Session                                                | No          |
| `GET`        | `/oauth2/get-consents`                                   | `getOAuthConsents`             | Session                                                | No          |
| `POST`       | `/oauth2/update-consent`                                 | `updateOAuthConsent`           | Session                                                | No          |
| `POST`       | `/oauth2/delete-consent`                                 | `deleteOAuthConsent`           | Session                                                | No          |
| `POST`       | `/admin/oauth2/resources`                                | `adminCreateOAuthResource`     | Session and `resourcePrivileges`                       | Yes         |
| `GET`        | `/admin/oauth2/resources`                                | `adminListOAuthResources`      | Session and `resourcePrivileges`                       | Yes         |
| `GET`        | `/admin/oauth2/resources/:identifier`                    | `adminGetOAuthResource`        | Session and `resourcePrivileges`                       | Yes         |
| `PATCH`      | `/admin/oauth2/resources/:identifier`                    | `adminUpdateOAuthResource`     | Session and `resourcePrivileges`                       | Yes         |
| `DELETE`     | `/admin/oauth2/resources/:identifier`                    | `adminDeleteOAuthResource`     | Session and `resourcePrivileges`                       | Yes         |
| `POST`       | `/admin/oauth2/resources/:identifier/clients/:client_id` | `adminLinkClientResource`      | Session and `resourcePrivileges`                       | Yes         |
| `DELETE`     | `/admin/oauth2/resources/:identifier/clients/:client_id` | `adminUnlinkClientResource`    | Session and `resourcePrivileges`                       | Yes         |

The provider also serves both well-known documents at their issuer-relative paths ([RFC 8414 §3](https://datatracker.ietf.org/doc/html/rfc8414#section-3)). Their `auth.api` methods are server-only. In the browser, `oauthProviderClient()` adds the signed `oauth_query` from the page URL to POST requests. `/oauth2/register` rejects requests unless `allowDynamicClientRegistration` is on, and `/oauth2/client/rotate-keys` unless `allowClientKeyRotation` is on.

## Token errors [#token-errors]

Errors use the [RFC 6749 §5.2](https://datatracker.ietf.org/doc/html/rfc6749#section-5.2) format. The built-in grants authenticate the client before they look up the grant, so an unknown `client_id` returns `invalid_client`, not `invalid_grant`.

| Error                    | Status | Returned when                                                                                                                            |
| ------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `invalid_request`        | 400    | A required request field is missing or malformed.                                                                                        |
| `invalid_client`         | 400    | Client authentication failed, or the client used a method other than its registered `token_endpoint_auth_method`.                        |
| `invalid_client`         | 401    | `client_secret_basic` authentication failed. The response carries a `WWW-Authenticate: Basic` challenge.                                 |
| `invalid_grant`          | 400    | The code, refresh token, or PKCE verifier is invalid, expired, or issued to another client.                                              |
| `unauthorized_client`    | 400    | The client is not registered for the grant, a public client used `client_credentials`, or the client has no `client_credentials` scopes. |
| `unsupported_grant_type` | 400    | The grant is not in `grantTypes` and no extension handles it.                                                                            |
| `invalid_scope`          | 400    | A requested scope is not allowed for the client, or none are allowed for a requested resource.                                           |
| `invalid_target`         | 400    | A `resource` is unknown, disabled, or not linked to the client while `enforcePerClientResources` is on.                                  |
| `invalid_dpop_proof`     | 400    | The DPoP proof is missing or invalid for a DPoP-bound client or resource.                                                                |

When `adminCreateOAuthClient` links resources, an invalid identifier fails validation with `VALIDATION_ERROR`, an unknown or disabled resource returns `invalid_target`, and a `resourcePrivileges` denial returns `401`. The call creates nothing in each case.

## Rate limits [#rate-limits]

Limits apply per IP and per endpoint, and only when Better Auth [rate limiting](/docs/concepts/rate-limit) is enabled, which by default is production only.

| Option key         | Endpoint                     | Window | Max |
| ------------------ | ---------------------------- | ------ | --- |
| `token`            | `/oauth2/token`              | 60s    | 20  |
| `authorize`        | `/oauth2/authorize`          | 60s    | 30  |
| `introspect`       | `/oauth2/introspect`         | 60s    | 100 |
| `revoke`           | `/oauth2/revoke`             | 60s    | 30  |
| `register`         | `/oauth2/register`           | 60s    | 5   |
| `rotateClientKeys` | `/oauth2/client/rotate-keys` | 60s    | 5   |
| `userinfo`         | `/oauth2/userinfo`           | 60s    | 60  |

Override a limit with `{ window, max }`, or set it to `false` to fall back to the global limits:

```ts title="auth.ts"
oauthProvider({
  rateLimit: {
    token: { window: 60, max: 50 },
    introspect: false,
  },
})
```

## Provider extensions [#provider-extensions]

Companion plugins call `extendOAuthProvider()` from their `init()` hook to add token grants, assertion-based client authentication, discovery metadata, claims, and client-id discovery. The [CIMD plugin](/docs/plugins/cimd) uses this to add URL-based client discovery.

```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"] });
          },
        },
        metadata: () => ({ custom_grant_supported: true }),
      });
    },
  }) satisfies BetterAuthPlugin;
```

* `grants` and `clientAuthentication` must not overlap across extensions. A duplicate grant type, auth method, or assertion type fails at startup. Both are advertised in discovery.
* `metadata` and `claims` only add keys. A key the provider owns is kept, and a key two extensions contribute resolves to the first one registered. To advertise extension claims, set `advertisedMetadata.claims_supported`.
* A `clientDiscovery` entry has a stable `id` stored as client provenance. Changing it requires migrating the owned clients, and removing it makes them fail closed.

A grant handler receives `provider` (`getClient`, `authenticateClient`, `issueTokens`, `hashToken`, `validateAccessToken`, `requireActiveAccessToken`). Other endpoints get the same object from `getOAuthProviderApi(ctx, opts, grantType?)`. Without `grantType`, `issueTokens` throws. `validateAccessToken` can return an inactive payload; protected endpoints should use `requireActiveAccessToken`, which rejects inactive tokens with a bearer challenge.

<Callout type="warn">
  `issueTokens` does not check that `scopes`, `user`, or `resources` are allowed for the client. Validate them in your handler before calling it.
</Callout>

To bind a token with `cnf` ([RFC 7800 §3](https://datatracker.ietf.org/doc/html/rfc7800#section-3)), pass `confirmation` to `issueTokens` or return it from a `clientAuthentication` strategy. Claim contributors cannot set `cnf`.

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

A `clientAuthentication` strategy proves which client sent the assertion and returns its `clientId`. The provider loads and authorizes the client record itself. After verifying the signature, the strategy must bind the audience, bound the lifetime, and reject replayed `jti` values ([RFC 7523 §3](https://datatracker.ietf.org/doc/html/rfc7523#section-3)). `consumeClientAssertion` performs all three:

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

authenticate: async ({ ctx, opts, assertion, expectedAudience }) => {
  if (!expectedAudience) throw new APIError("BAD_REQUEST", { error: "invalid_client" });
  const payload = await verifyAssertionSignature(assertion); // your key source
  await consumeClientAssertion(ctx, opts, {
    namespace: `urn:example:attestation:${payload.sub}`,
    payload,
    expectedAudience,
  });
  return { clientId: payload.sub };
};
```

### Claim precedence [#claim-precedence]

Extension claims only add claim names. Your own callbacks can override profile claims. The provider always owns identity, lifetime, binding, and authentication-context claims.

| Token        | Order, lowest to highest authority                                                                                                                            | Owned by the provider                                                                                                            |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Access token | extension `claims.accessToken` \< per-issuance `accessTokenClaims` \< `customAccessTokenClaims` \< per-resource `customClaims`                                | `iss`, `sub`, `aud`, `exp`, `iat`, `jti`, `client_id`, `scope`, `auth_time`, `acr`, `amr`, `cnf`, stripped before signing        |
| ID token     | subject and authentication claims \< `customIdTokenClaims`. Extension and per-issuance `idTokenClaims` only add names and cannot set profile or email claims. | `iss`, `sub`, `aud`, `exp`, `nbf`, `iat`, `jti`, `nonce`, `sid`, `at_hash`, `c_hash`, `s_hash`, `auth_time`, `acr`, `amr`, `azp` |
| UserInfo     | scope and `claims.userinfo` claims \< extension `claims.userInfo` (add only) \< `customUserInfoClaims`                                                        | `sub`, applied last                                                                                                              |

Per-issuance `accessTokenClaims` exist only in JWT access tokens. A claim that introspection of an opaque token must return belongs in a `claims.accessToken` contributor that does not depend on the grant type.

## Schema [#schema]

The plugin adds the following tables. Run `npx auth generate` for the exact schema of your adapter.

### OAuth Client [#oauth-client]

Table Name: `oauthClient`



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

### OAuth Resource [#oauth-resource]

Table Name: `oauthResource`

A `null` policy field uses the plugin default at issuance.



<DatabaseTable name="oauthResource" fields="oauthResourceTableFields" />

### OAuth Client Resource [#oauth-client-resource]

Table Name: `oauthClientResource`

Links a client to a resource. The pair `(clientId, resourceId)` is unique. `enforcePerClientResources` controls whether authorization and token requests check these rows. [Introspection](/docs/plugins/oauth-provider/resource-server#introspection) always uses them to decide whether a resource server can introspect another client's token.



<DatabaseTable name="oauthClientResource" fields="oauthClientResourceTableFields" />

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

Table Name: `oauthRefreshToken`



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

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

Table Name: `oauthAccessToken`

Stores opaque access tokens only. JWT access tokens are not stored.



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

### OAuth Consent [#oauth-consent]

Table Name: `oauthConsent`



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

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

Table Name: `oauthClientAssertion`

Records each `private_key_jwt` assertion `jti` so it can be used once. The check needs string IDs, so with `advanced.database.generateId: "serial"` Better Auth logs a warning. Rows are never pruned; delete expired rows.



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

## Optimizations [#optimizations]

Database adapters may map `clientId` on `oauthClient` to `id` for faster lookups. The `id` column must then accept strings shaped like UUIDs and URLs.

## Related [#related]

<Cards>
  <Card href="/docs/plugins/oauth-provider" title="OAuth Provider setup">
    Install the plugin and run your first authorization code flow.
  </Card>

  <Card href="/docs/plugins/oauth-provider/clients" title="Clients and registration">
    Register, manage, and authenticate OAuth clients.
  </Card>

  <Card href="/docs/plugins/oauth-provider/tokens" title="Tokens, scopes, and resources">
    Control which tokens the provider issues and for how long.
  </Card>

  <Card href="/docs/plugins/cimd" title="Client ID Metadata Documents">
    Identify clients by a metadata document URL.
  </Card>
</Cards>

