# Tokens, scopes, and resources (/docs/plugins/oauth-provider/tokens)

Control which tokens the provider issues, for which APIs, and for how long.



## Token endpoint [#token-endpoint]

`POST /oauth2/token` supports the `authorization_code`, `client_credentials`, and `refresh_token` grants. A confidential client must authenticate with the method it registered, using one of the methods from [RFC 6749 §2.3.1](https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1) and [OIDC Core §9](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication):

| Method                | How the client authenticates                                                                         |
| --------------------- | ---------------------------------------------------------------------------------------------------- |
| `client_secret_basic` | `client_id` and `client_secret` in an HTTP Basic `Authorization` header (default)                    |
| `client_secret_post`  | `client_id` and `client_secret` in the request body                                                  |
| `private_key_jwt`     | A signed JWT assertion. See [Private key JWT](/docs/plugins/oauth-provider/clients#private-key-jwt). |
| `none`                | Public client. PKCE is required.                                                                     |

To get a JWT access token, include a `resource` in the request and enable the JWT plugin. The token's `aud` is that resource. When the scopes also include `openid`, `aud` is an array that also holds the UserInfo endpoint URL. If either is missing, the access token is opaque. Error codes are listed in [Token errors](/docs/plugins/oauth-provider/reference#token-errors).

## Scopes [#scopes]

To control which scopes your clients may request, set `scopes`. It defaults to `["openid", "profile", "email", "offline_access"]`. Your provider acts as an OpenID provider only when the list includes `openid`.

| Scope            | Grants                                                         |
| ---------------- | -------------------------------------------------------------- |
| `openid`         | An ID token and the `sub` claim                                |
| `profile`        | `name`, `picture`, `given_name`, and `family_name` at UserInfo |
| `email`          | `email` and `email_verified` at UserInfo                       |
| `offline_access` | A refresh token                                                |

Standard claims are returned at UserInfo, not in the ID token, as [OIDC Core §5.4](https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims) describes for flows that issue an access token.

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

## Resources [#resources]

A resource is a protected API that your provider issues access tokens for. Its `identifier` is the `resource` parameter value from [RFC 8707 §2](https://datatracker.ietf.org/doc/html/rfc8707#section-2) and becomes the `aud` of a JWT access token.

<Callout type="warn">
  A client can get access tokens only for resources it is linked to. The authorize and token endpoints reject any other `resource` with `invalid_target`. To link a client, pass `resources` to `adminCreateOAuthClient` or call `adminLinkClientResource`. Setting `enforcePerClientResources: false` lets any client request any enabled resource, which removes the per-client audience restriction. With dynamic registration or CIMD, a client anyone registered could then get tokens for every enabled API. Keep the default, `true`, and link clients explicitly or through `clientRegistrationDefaultResources`.
</Callout>

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

A string uses the plugin defaults. An object sets per-resource policy:

| Field                           | Effect                                                                                              |
| ------------------------------- | --------------------------------------------------------------------------------------------------- |
| `name`                          | Label for admin screens.                                                                            |
| `allowedScopes`                 | Requested scopes are narrowed to this list. If none remain, the request fails with `invalid_scope`. |
| `accessTokenTtl`                | Access token lifetime in seconds. Defaults to `accessTokenExpiresIn`.                               |
| `refreshTokenTtl`               | Refresh token lifetime in seconds. Defaults to `refreshTokenExpiresIn`.                             |
| `signingAlgorithm`              | JWS algorithm for this resource's access tokens. Defaults to the JWT plugin's latest key.           |
| `signingKeyId`                  | Key ID to sign this resource's access tokens with.                                                  |
| `customClaims`                  | Claims added to every access token for this resource. Reserved JWT claims are stripped.             |
| `dpopBoundAccessTokensRequired` | Issue only [DPoP](#dpop)-bound tokens for this resource.                                            |
| `disabled`                      | Stop issuing tokens for this resource. Tokens already issued still verify until they expire.        |

In a request for several resources, a `signingAlgorithm` or `signingKeyId` set on one of them applies to the whole token, and resources without one do not conflict with it. The request fails with `invalid_request` only when two resources set different values, because one token carries one signature. To fix it, set the same value on both resources, or leave it unset on one of them.

The resources you configure are written to the `oauthResource` table at startup. Set `resourceSeedMode` to choose what happens to a row that already exists:

* `insertOnly` (default): only adds missing rows, so edits made through the admin endpoints survive restarts.
* `merge`: adds missing rows and updates the fields present in the config.
* `overwrite`: replaces existing rows with the config values.

### Manage resources at runtime [#manage-resources-at-runtime]

To change resources without a restart, call these server-only endpoints. Each one needs a session. `resourcePrivileges` decides which users may call them. Without `resourcePrivileges`, any signed-in user may.

| Server method               | Method   | Path                                                     |
| --------------------------- | -------- | -------------------------------------------------------- |
| `adminCreateOAuthResource`  | `POST`   | `/admin/oauth2/resources`                                |
| `adminListOAuthResources`   | `GET`    | `/admin/oauth2/resources`                                |
| `adminGetOAuthResource`     | `GET`    | `/admin/oauth2/resources/:identifier`                    |
| `adminUpdateOAuthResource`  | `PATCH`  | `/admin/oauth2/resources/:identifier`                    |
| `adminDeleteOAuthResource`  | `DELETE` | `/admin/oauth2/resources/:identifier`                    |
| `adminLinkClientResource`   | `POST`   | `/admin/oauth2/resources/:identifier/clients/:client_id` |
| `adminUnlinkClientResource` | `DELETE` | `/admin/oauth2/resources/:identifier/clients/:client_id` |

```ts title="link-resource.ts"
await auth.api.adminLinkClientResource({
  params: { identifier: "https://api.example.com", client_id: clientId },
  headers,
});
```

Deleting a resource makes introspection report its tokens as inactive, while `disabled: true` only stops new issuance. If your API verifies JWTs locally with the JWKS, it keeps accepting them until they expire. Use [introspection](/docs/plugins/oauth-provider/resource-server#introspection) if you need deletion to cut off access immediately. To attach resources during dynamic registration, see [Resources for registered clients](/docs/plugins/oauth-provider/clients#resources-for-registered-clients).

<Callout type="warn">
  If you use Prisma on PostgreSQL, CockroachDB, or MongoDB, declare `allowedScopes` on every resource. Prisma cannot write `null` to a list column, so a resource without `allowedScopes` cannot be stored. Requests that use any configured resource fail until every resource declares it. For the resource the [MCP plugin](/docs/plugins/mcp) adds, declare it in `resources` with the same identifier.
</Callout>

## Lifetimes [#lifetimes]

All values are in seconds.

| Option                    | Default             | Applies to                              |
| ------------------------- | ------------------- | --------------------------------------- |
| `accessTokenExpiresIn`    | `3600` (1 hour)     | Access tokens issued for a user         |
| `m2mAccessTokenExpiresIn` | `3600` (1 hour)     | Access tokens from `client_credentials` |
| `idTokenExpiresIn`        | `36000` (10 hours)  | ID tokens                               |
| `refreshTokenExpiresIn`   | `2592000` (30 days) | Refresh tokens                          |
| `codeExpiresIn`           | `600` (10 minutes)  | Authorization codes                     |
| `assertionMaxLifetime`    | `300` (5 minutes)   | `private_key_jwt` client assertions     |

A refresh token's lifetime restarts at each refresh: the replacement token gets a new `refreshTokenExpiresIn` window. A client that refreshes regularly therefore keeps its grant indefinitely. To end a grant, [revoke](#revocation) the refresh token. Deleting the user's [consent](/docs/plugins/oauth-provider/sign-in-flow#manage-consents) does not revoke tokens already issued.

To shorten access tokens that carry high-privilege scopes, set `scopeExpirations`. A resource's `accessTokenTtl` and `refreshTokenTtl` can also shorten these lifetimes, but not extend them. When several limits apply, the earliest expiry wins. For example, with the config below, a token with both `read:payments` and `write:payments` expires after 5 minutes.

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

## Refresh tokens [#refresh-tokens]

Each refresh issues a new refresh token and invalidates the old one, following [OAuth 2.1 §4.3.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1#section-4.3.1). Reusing a rotated refresh token is treated as replay: every refresh token the user holds for that client is revoked, and the opaque access tokens they issued are deleted. JWT access tokens stay valid until they expire. After a replay, the refresh fails with `invalid_grant`, and the client must start a new authorization request to get tokens.

Keep the default, `0`, unless your clients lose refresh responses. To let a client retry a refresh after a lost response, set `refreshTokenReuseInterval` to a number of seconds. A retry within that window gets the same response back, including the replacement refresh token. The retry gets that response only when it uses the same client, scopes, resources, and DPoP key as the first refresh. A retry that differs but is otherwise valid gets `invalid_grant`.

Inside the window, reuse is not treated as replay, and the token family is never revoked. A stolen rotated token that passes these checks therefore gets the replacement refresh token. Keep the interval short.

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

To change the string sent to the client, set `formatRefreshToken`. `encrypt` receives the generated refresh token (before it is hashed for storage) and, when there is one, the session ID. It returns the string sent to the client, before the provider adds `prefix.refreshToken`. `decrypt` receives the string the client sends back, without the `prefix.refreshToken` prefix. It must return an object with the `token` that `encrypt` received and, optionally, the `sessionId`.

```ts title="auth.ts"
oauthProvider({
  formatRefreshToken: {
    encrypt: (token, sessionId) => (sessionId ? `1.${token}.${sessionId}` : token),
    decrypt: (value) => {
      const [version, token, sessionId] = value.split(".");
      return version === "1" && token && sessionId ? { token, sessionId } : { token: value };
    },
  },
})
```

## PKCE [#pkce]

PKCE ([RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636#section-1)) is **required** for every authorization code flow, and only the `S256` method is accepted. To let an admin-created confidential client skip PKCE, set `require_pkce: false` on it. That client then has no protection against intercepted or injected authorization codes beyond its secret and `state` ([RFC 9700 §2.1.1](https://datatracker.ietf.org/doc/html/rfc9700#section-2.1.1)). Public clients cannot opt out. For dynamically registered confidential clients, set the default with `clientRegistrationRequirePKCE`.

```ts title="admin-create-client.ts"
await auth.api.adminCreateOAuthClient({
  headers,
  body: {
    redirect_uris: ["https://app.example.com/callback"],
    token_endpoint_auth_method: "client_secret_post",
    require_pkce: false, // [!code highlight]
  },
})
```

A client without PKCE that requests `offline_access` must send both `openid` and `nonce`. Keep PKCE on unless a legacy client cannot send it, as [OAuth 2.1 §7.5.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1#section-7.5.1) recommends.

## DPoP [#dpop]

DPoP stops anyone who lacks the client's private key from replaying a leaked access token, as long as your API verifies the proof. A DPoP-bound token works only alongside a proof signed by that key.

A client gets DPoP-bound tokens ([RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449#section-4)) in any of these cases:

* It registered with `dpop_bound_access_tokens: true` ([§5.2](https://datatracker.ietf.org/doc/html/rfc9449#section-5.2)).
* It sends `dpop_jkt` on the authorization request.
* It requests a resource with `dpopBoundAccessTokensRequired: true`.

The token endpoint then requires a DPoP proof ([§5](https://datatracker.ietf.org/doc/html/rfc9449#section-5)) and returns `token_type: "DPoP"`. A refresh needs a proof from the same key. A JWT access token carries the key's thumbprint in `cnf.jkt`. The client calls your API with `Authorization: DPoP <access_token>` and a `DPoP` proof that includes the access token hash, `ath` ([§7](https://datatracker.ietf.org/doc/html/rfc9449#section-7)).

```ts title="auth.ts"
oauthProvider({
  dpop: {
    proofMaxAgeSeconds: 300, // 5 minutes, in seconds (the default)
    signingAlgorithms: ["ES256", "EdDSA"],
  },
})
```

<Callout type="warn">
  Set `baseURL` to the public URL clients call. Otherwise proofs fail behind a proxy that rewrites the host, because the expected `htu` comes from `baseURL`, not from the request host.
</Callout>

To verify DPoP-bound tokens in your API, see [Protect your API](/docs/plugins/oauth-provider/resource-server#verify-access-tokens).

## Custom claims [#custom-claims]

To add claims to access tokens, set `customAccessTokenClaims`. It receives `user`, `scopes`, `referenceId`, `resources`, and the client's `metadata`. It returns an object of claims to add. Use namespaced claim names, and see [RFC 9068 §2.2](https://datatracker.ietf.org/doc/html/rfc9068#section-2.2) for the claims the provider already sets. To stop a token from being issued, for example when the user has left the organization, throw from the callback.

```ts title="auth.ts"
oauthProvider({
  customAccessTokenClaims: ({ user, scopes, referenceId, resources }) => ({
    "https://example.com/org": referenceId,
    "https://example.com/roles": ["editor"],
  }),
})
```

For ID token and UserInfo claims, see [OpenID Connect](/docs/plugins/oauth-provider/openid-connect#id-token-claims).

### Client-specific access token claims [#client-specific-access-token-claims]

`customAccessTokenClaims` receives only the client's `metadata`. To base a claim on the full client record, add an `extensions[].claims.accessToken` contributor. It receives the full `client` record, `user`, `scopes`, `referenceId`, `resources`, and the client's `metadata`. When a JWT access token is minted, it also receives `grantType`, and `sessionId` for a session-backed grant. It returns an object of claims to add:

```ts title="auth.ts"
oauthProvider({
  extensions: [{
    claims: {
      accessToken: ({ client }) =>
        client.referenceId ? { "https://example.com/org": client.referenceId } : {},
    },
  }],
})
```

`client.referenceId` is the client's owner, set by [`clientReference`](/docs/plugins/oauth-provider/clients#organizations). It differs from the top-level `referenceId`, which is the consent reference from `postLogin.consentReferenceId`. Opaque-token introspection re-runs contributors without `grantType` or `sessionId`, so base the claim on the client, scopes, and resources. When names collide, see [Claim precedence](/docs/plugins/oauth-provider/reference#claim-precedence).

A JWT keeps its claims until it expires. An organization claim does not prove the client is still enabled. If your resource server must react to a disabled client right away, it should introspect the token. Introspection reports tokens of disabled clients as inactive.

### Custom token response fields [#custom-token-response-fields]

To add fields to the token endpoint's JSON response, set `customTokenResponseFields`. It receives `grantType`, `user`, `scopes`, and the client's `metadata`. For the `authorization_code` grant, it also receives `verificationValue`, which holds the original authorization request (`query`), the consent `referenceId`, and the `sessionId`. It returns an object of fields to add. The fields go in the response, not in the token itself. The callback cannot override standard fields such as `access_token` or `scope`. It runs before any token is created.

```ts title="auth.ts"
oauthProvider({
  customTokenResponseFields: ({ grantType, verificationValue }) =>
    grantType === "authorization_code" && verificationValue?.referenceId
      ? { tenant_id: verificationValue.referenceId }
      : {},
})
```

## Revocation [#revocation]

`POST /oauth2/revoke` implements [RFC 7009 §2.1](https://datatracker.ietf.org/doc/html/rfc7009#section-2.1). What it does depends on the token:

* **Opaque access token**: deleted. Refresh tokens from the same grant stay valid.
* **Refresh token**: revoked, and the opaque access tokens it issued are deleted. JWT access tokens it issued stay valid until they expire.
* **JWT access token**: cannot be revoked, because it is never stored. A valid one returns 400 `unsupported_token_type` ([RFC 7009 §2.2.1](https://datatracker.ietf.org/doc/html/rfc7009#section-2.2.1)).

To limit a JWT access token, keep `accessTokenExpiresIn` short or end the user's session. A JWT that carries a `sid` is reported inactive by introspection and rejected by UserInfo once its session ends. A `client_credentials` JWT has no session. Its lifetime is the only limit.

## Without the JWT plugin [#without-the-jwt-plugin]

To run without the [JWT plugin](/docs/plugins/jwt), set `disableJwtPlugin: true`:

* Access tokens are always opaque, even when a `resource` is requested. The requested resources are still bound to the token and returned by introspection.
* ID tokens are signed with HS256 using the client secret. Public clients have no secret and get no ID token. They can still call UserInfo with the access token.
* `storeClientSecret` defaults to `"encrypted"`, since the secret must be readable to sign ID tokens.

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

## Storage [#storage]

By default, your database stores opaque access tokens and refresh tokens as hashes. Client secrets are hashed with the JWT plugin and encrypted without it.

* `storeClientSecret`: `"hashed"` or `{ hash, verify? }` with the JWT plugin; `"encrypted"` or `{ encrypt, decrypt }` without it. The provider fails to start on any other combination.
* `storeTokens`: `"hashed"` or `{ hash: (token, type) => string }`.

To make issued values recognizable to secret scanners, set `prefix`:

```ts title="auth.ts"
oauthProvider({
  prefix: {
    opaqueAccessToken: "myapp_at_",
    refreshToken: "myapp_rt_",
    clientSecret: "myapp_cs_",
  },
})
```

<Callout type="warn">
  Set prefixes before your first production deployment and keep them fixed afterwards. To add a prefix to a provider that has already issued values, use `generateOpaqueAccessToken`, `generateRefreshToken`, or `generateClientSecret` instead.
</Callout>

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

To enable the device code grant ([RFC 8628 §3.4](https://datatracker.ietf.org/doc/html/rfc8628#section-3.4)) at `/oauth2/token`, add `oauthDeviceAuthorization()` next to `oauthProvider()`. See [Device Authorization](/docs/plugins/device-authorization) for setup.

