MCP OAuth Integration#
The @better-auth/mcp package turns a Better Auth instance into an OAuth 2.1 authorization server for Model Context Protocol clients. It is a thin wrapper over @better-auth/oauth-provider: the mcp() plugin calls oauthProvider() internally, binds issued tokens to a required resource identifier (RFC 8707), registers RFC 9728 protected-resource metadata, and defaults refreshTokenReuseInterval to 30 seconds so retried refresh requests can recover a rotated response .
Legacy vs. new. An older
mcpplugin ships insidebetter-auth/pluginson top of the deprecatedoidc-provider, with endpoints under/mcp/*. The standalone@better-auth/mcppackage (introduced in v1.7.0-beta.6) moves endpoints to/oauth2/*and is the actively maintained path .
JWT Plugin Alignment and JWKS Endpoint#
The JWT plugin (better-auth/plugins → jwt()) is required by @better-auth/mcp . It performs two roles:
-
Signing key provider — when a
resourceaudience is included in a token request,createJwtAccessTokencallsgetJwtPlugin(ctx.context)and delegates signing to the JWT plugin . OAuth access tokens and ordinary session JWTs share the same issuer, algorithm, and key pair. -
JWKS endpoint — the JWT plugin registers
GET /jwks. The introspection endpoint fetches this key set to verify JWT access tokens: it prefers an in-process call tojwtPlugin.endpoints.getJwks(ctx)when nojwks.remoteUrlis configured, and passes the plugin instance as aWeakMap-keyed cache entry (jwksCacheKey) so the key set is reused across requests rather than fetched per call .
Guard against plain session JWTs. Because session JWTs and OAuth access tokens share the same signing key, validateJwtAccessToken rejects any token that lacks the azp (authorized party) claim. Only tokens issued by the OAuth token endpoint carry azp; plain session JWTs do not .
Token Introspection Endpoint (POST /oauth2/introspect)#
Implemented in introspectEndpoint, per RFC 7662. Requires client credentials (Basic auth or client_id/client_secret body params). The legacy plugin has no equivalent .
Resolution order (when no token_type_hint): JWT access token → opaque access token → refresh token .
| Token type | Validator | Key behavior |
|---|---|---|
| JWT access token | validateJwtAccessToken | Verifies signature via JWKS; requires azp; optionally validates linked session via sid |
| Opaque access token | validateOpaqueAccessToken | DB lookup by token hash; checks expiry, user, session |
| Refresh token | validateRefreshToken | Checks expiry, clientId binding, revoked flag |
Custom claims in responses:
- Opaque tokens:
validateOpaqueAccessTokencalls thecustomAccessTokenClaimshook and merges the result into the introspection response — custom claims are re-evaluated at introspection time . - JWT tokens: the full decoded JWT payload (including custom claims baked in at issuance by
createJwtAccessToken) is returned directly .
Response shape (RFC 7662 §2.2): active, sub, scope, client_id, exp, iat, iss, sid, plus any custom claims. BAD_REQUEST errors are swallowed and returned as { active: false } . Pairwise sub identifiers are resolved at the presentation layer by resolveIntrospectionSub before the response is emitted .
Protected Resource Metadata (RFC 9728)#
The mcp() plugin intercepts GET /.well-known/oauth-protected-resource (and a path-inserted alias) and returns an RFC 9728 document built by buildResourceServerMetadata . The document includes:
resource— the canonical MCP resource URIauthorization_servers— the provider's issuer URLscopes_supported— resource-scoped scopes only (OIDC identity scopes andoffline_accessare filtered out)bearer_methods_supported: ["header"]dpop_signing_alg_values_supported— from provider DPoP config
Endpoint Map#
| Endpoint | Legacy plugin | @better-auth/mcp |
|---|---|---|
| Authorization | GET /mcp/authorize | GET /oauth2/authorize |
| Token | POST /mcp/token | POST /oauth2/token |
| JWKS | GET /mcp/jwks | GET /jwks (JWT plugin) |
| Introspection | (none) | POST /oauth2/introspect |
| Auth server discovery | GET /.well-known/oauth-authorization-server | same |
| Protected resource discovery | GET /.well-known/oauth-protected-resource | same |
Key Source Files#
| File | Role |
|---|---|
packages/mcp/src/plugin.ts | mcp() plugin: oauthProvider wrapper, resource binding, RFC 9728 metadata |
packages/oauth-provider/src/introspect.ts | introspectEndpoint, JWT/opaque/refresh validators, azp guard |
packages/oauth-provider/src/token.ts | Token issuance: createJwtAccessToken, tokenEndpoint |
packages/core/src/oauth2/verify.ts | JWKS caching + JWT signature verification primitives |
packages/better-auth/src/plugins/jwt/index.ts | JWT plugin: getJwks endpoint, signing key management |