client_credentials: a confidential client gets a token for itself, with no user involved.- Token exchange (RFC 8693): trade an access token this server issued for a narrower one, optionally for another resource.
jwt-bearer(RFC 7523): redeem an Identity Assertion JWT Authorization Grant (ID-JAG) signed by an identity provider you trust.private_key_jwt: authenticate a client with a signed assertion instead of a shared secret.
config object you pass to createMcpModule. See MCP OAuth 2.1 for the base setup.
What changes in the metadata
Each enabled grant is added togrant_types_supported in the authorization server metadata. private_key_jwt and its algorithm list (token_endpoint_auth_signing_alg_values_supported) are always advertised. A grant that is not enabled answers unsupported_grant_type.
Register the client for the grant
A client can only use a grant it is registered for. Through dynamic registration, list the grant type ingrant_types:
Registration refuses a grant type that is not enabled on the server, and refuses
client_credentials for a client with token_endpoint_auth_method: "none". For clients you pre-register in config, set grantTypes on the entry in preRegisteredClients. The default there is authorization_code and refresh_token only.
All three grants require a resource (RFC 8707) unless lenientClients.optionalResource is on and exactly one resource is configured. None of them issues a refresh token, and offline_access is never granted.
client_credentials
resolvePrincipal is required. Without it no client can obtain a token, so a client created through open registration is not enough on its own.
The token request:
- The client must be confidential: a secret (
client_secret_basicorclient_secret_post) orprivate_key_jwt. - The token
subis the principal’ssubject, never a user. - Scopes are bounded by
principal.scopes, falling back to the client’s registeredscope. With neither, the request fails withinvalid_scope. A request can narrow within that bound, never widen. - No refresh token is issued. Send a DPoP proof and the token is bound to that key (
token_type: "DPoP").
Token exchange (RFC 8693)
private_key_jwt can also revoke tokens at /mcp/revoke. The assertion audience must be the revoke endpoint URL or the issuer, an assertion addressed to the token endpoint is refused there, and the jti store is shared with the token endpoint. An assertion combined with basic auth or client_secret is rejected.
createMcpModule throws at startup if tokenExchange.enabled is set without jtiDenylist. The denylist is what makes a revoked subject token fail the exchange. See the multi-instance note below.
The request carries a subject_token (and optionally an actor_token), both with ..._token_type=urn:ietf:params:oauth:token-type:access_token. Only access tokens issued by this server are accepted. Other token types are rejected with invalid_request.
The
act claim names the actor: the actor token’s user and client when you send one, otherwise the exchanging client. Earlier hops are nested inside it, and resource servers see them as principal.delegationChain (see DPoP and requireMcpAuth). The response includes issued_token_type.
jwt-bearer and ID-JAG (RFC 7523)
An enterprise identity provider signs an assertion that says “this user may use this client at this resource”. The client redeems it at the token endpoint.trustedIssuers must have at least one entry and mapSubject is required, or startup throws. Each issuer takes jwksUri (HTTPS, fetched through the SSRF-safe fetcher), static jwks, or both. Static keys are tried first, and a jwksUri is consulted when no static key matches. Fetched key sets are cached in process for five minutes, and an unknown kid triggers at most one refetch every 30 seconds. If a refetch fails, the stale copy is used for up to 30 minutes.
The assertion must have JOSE header typ: oauth-id-jag+jwt and claims iss, sub, aud, exp, iat, jti and client_id. The checks:
issis one of the trusted issuers, and the signature verifies against its keys.audequals this server’sissuer.client_idequals the authenticated client.exp - iatis at mostmaxLifetimeSeconds.jtihas not been seen from that issuer. Replays are tracked inassertionStorage.- An optional
scopeclaim bounds the scopes (defaultopenid), and an optionalresourceclaim must agree with the request.
private_key_jwt client authentication
Register the client with public keys instead of a secret:jwks (public keys only, private members are refused) or jwks_uri (public HTTPS). Client ID Metadata Documents can declare private_key_jwt too. No secret is generated for these clients.
At the token endpoint the client sends client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer and a client_assertion. The assertion must have:
issandsubboth equal to theclient_id.audequal to the token endpoint URL (dpop.tokenEndpointUrl, else<baseUrl>/mcp/token) or the issuer.expno more than 300 seconds out (30 seconds of skew allowed), and ajti.- A signature in ES256, ES384, EdDSA, PS256 or RS256. HMAC and
noneare never accepted.
jti works once per client. Do not send a client_secret or Basic header alongside an assertion: the request is refused.
Multi-instance deployments
Two stores default to process memory and break down behind a load balancer.jtiDenylist. createInMemoryJtiDenylist() is per process. A token revoked at /mcp/revoke on one instance still exchanges on another. Pass your own McpJtiDenylist (revoke(jti, expiresAt) and isRevoked(jti)) backed by Redis or your database.
assertionStorage. The replay cache for private_key_jwt assertions and jwt-bearer jti values defaults to an in-process store. With several instances an assertion can be replayed once per instance. Give every instance the same shared store:
incr. Cloudflare KV is not atomic (atomicIncr is false), so two simultaneous replays can both pass there.
Related
MCP client compatibility
Relax strict request checks for real-world MCP clients.
DPoP and requireMcpAuth
Bind these tokens to a client key and read the delegation chain.
Delegation
Agent delegation chains, separate from RFC 8693 exchange.