OAuth and OIDC Security#
Overview#
better-auth has two generations of OAuth/OIDC support with distinct security profiles:
- Legacy plugins (
oidc-provider,mcpinpackages/better-auth/src/plugins/): Both have cross-cutting gaps around token signing keys and JWKS metadata. The MCP plugin has since moved topackages/mcp. @better-auth/oauth-provider(current): A rewrite that requires the JWT plugin and uses asymmetric signing + a proper JWKS verification pipeline by default.
The legacy plugins are deprecated. New integrations should use @better-auth/oauth-provider.
Issue 1 — OIDC Provider: HS256 by Default, No Verifiable JWKS#
Default behavior: The legacy oidc-provider signs ID tokens with HS256 using the OAuth client's stored clientSecret as the HMAC key. Three compounding problems follow:
- No third-party verification. JWKS serves asymmetric public keys; an HS256 token signed with a per-client secret has no public key to publish. Clients cannot verify the token through the JWKS endpoint.
- Algorithm mismatch in discovery metadata. The discovery document previously advertised
id_token_signing_alg_values_supported: ["RS256", "none"]while signing withHS256. PR #2326 proposed correcting this to["HS256"]but was closed without merge. - Public client crash. A PKCE (public) client has no
clientSecret. Signing with an empty HMAC key throwsZero-length key is not supportedat runtime.
useJWTPlugin option: When set on OIDCOptions, the OIDC provider delegates ID token signing to the JWT plugin's asymmetric keys (RS256 or EdDSA), the discovery document advertises the correct algorithms, and jwks_uri points to the JWT plugin's /jwks endpoint. This opt-in path was proposed in PR #2912 and refined in PR #3341 ; both were closed without merge, with related behavior landing in PR #2755.
Issue 2 — MCP Plugin: Ephemeral HMAC Key + Phantom JWKS Endpoint#
The legacy MCP plugin has the most severe signing issue. At token issuance, it generates a brand-new HMAC-SHA-256 key via WebCrypto, signs the ID token with it, then discards the key. The signed token is structurally unverifiable after the response is sent.
This is compounded by metadata mismatches in getMCPProviderMetadata:
- Wrong algorithm advertised. Discovery says
id_token_signing_alg_values_supported: ["RS256"]but tokens are signed withHS256. - Non-existent JWKS endpoint. Discovery advertises
jwks_uri: ${baseURL}/mcp/jwks, but no/mcp/jwksroute exists. The same phantom URI appears ingetMCPProtectedResourceMetadata.
PR #6849 proposed advertising /jwks (the JWT plugin's real endpoint) when oidcConfig.useJWTPlugin: true is set; it was closed without merge.
Access token validation is unaffected. withMcpAuth validates tokens via session/DB lookup (getMcpSession), not by verifying the JWT signature. Only OIDC clients that consume and verify the id_token are broken.
@better-auth/oauth-provider: Current Token Verification Pipeline#
The current OAuth provider (packages/oauth-provider) implements a proper verification chain :
JWKS resolution — Introspect (validateJwtAccessToken) and revoke (revokeJwtAccessToken) both call getJwtPlugin to locate the JWT plugin, then fetch the JWKS via in-process call (no remoteUrl) or HTTP fetch (when jwks.remoteUrl is set). The JWT plugin instance is passed as a stable WeakMap-keyed jwksCacheKey to avoid re-fetching the key set on every request.
JWKS caching — Implemented in getJwksForVerification in packages/core/src/oauth2/verify.ts:
- URL sources: cached in a module-level
Mapkeyed by URL string - Function sources: cached in a
WeakMapkeyed by the plugin instance - 5-minute TTL
JWKS_CACHE_TTL_MS - Kid-miss invalidation: if the JWT's
kidisn't in the cached JWKS, a fresh fetch is triggered - No-kid refetch cooldown: when a token has no
kidand signature verification fails, retries are rate-limited to once per 30 secondsJWKS_NO_KID_REFETCH_COOLDOWN_MSto prevent invalid tokens from hammering the JWKS endpoint
JWT vs. opaque dispatch — Both endpoints try JWT verification first; on failure (non-JWT shape), they fall back to opaque DB lookup .
Guard against plain session JWTs — Because session JWTs share the same issuer, audience, and signing keys as OAuth access tokens, validateJwtAccessToken requires the azp (authorized party) claim — only stamped on tokens issued through the OAuth token endpoint — before reporting a token active. Plain session JWTs lack azp and are rejected.
Error handling — Unrecognized jose errors in introspect/revoke rethrow as INTERNAL_SERVER_ERROR . Recognized errors (JWTExpired, JWTInvalid) resolve to { active: false } or silent no-ops per RFC 7009/7662.
Secure Configuration#
For legacy plugins, configure asymmetric signing:
- Install the JWT plugin with RSA-2048 or EdDSA keys — this enables the
/jwksendpoint. - Set
useJWTPlugin: truein OIDC provider options; for MCP, pass it asoidcConfig: { useJWTPlugin: true }.
For new integrations, use @better-auth/oauth-provider, which mandates the JWT plugin and asymmetric signing by default.
Key Files#
| File | Role |
|---|---|
packages/core/src/oauth2/verify.ts | JWKS caching + token verification primitives (verifyJwsAccessToken, getJwks, getJwksForVerification) |
packages/oauth-provider/src/introspect.ts | Introspection endpoint — JWT/opaque dispatch, azp guard, session-liveness check |
packages/oauth-provider/src/revoke.ts | Revocation endpoint — mirrors introspect JWKS path; JWT tokens return unsupported_token_type per RFC 7009 |
packages/better-auth/src/plugins/mcp/index.ts (historical) | Legacy MCP plugin — ephemeral HMAC key signing, phantom JWKS URI |