OAuth JWKS Resolution#
The @better-auth/oauth-provider package verifies JWT tokens at its logout, introspect, and revoke endpoints by resolving a JSON Web Key Set (JWKS) and using it to validate token signatures. All three endpoints rely on primitives exported from packages/core/src/oauth2/verify.ts: getJwks (used by logout) and verifyJwsAccessToken (used by introspect and revoke via getJwks internally).
JWKS Source Selection#
Each endpoint first calls getJwtPlugin(ctx.context) to locate the JWT plugin registered on the Better Auth context. The plugin's jwks.remoteUrl option then determines the fetch strategy:
| Endpoint | remoteUrl set | remoteUrl not set |
|---|---|---|
| Introspect | HTTP fetch to remoteUrl | In-process call to jwtPlugin.endpoints.getJwks(ctx) |
| Revoke | HTTP fetch to remoteUrl | In-process call to jwtPlugin.endpoints.getJwks(ctx) |
| Logout | HTTP fetch to remoteUrl | HTTP fetch to ${baseURL}${jwksPath ?? "/jwks"} |
- Introspect —
validateJwtAccessTokenbuildsjwksFetchas either the URL string or an async closure calling the JWT plugin endpoint, then passes it (withjwksCacheKey: jwtPlugin) togetJwks. - Revoke —
revokeJwtAccessTokenuses identical logic: URL string or in-process closure, withjwksCacheKey: jwtPlugin. - Logout —
verifyLogoutHintselectsremoteUrlif configured, otherwise constructs a URL as${baseURL}${jwksPath ?? "/jwks"}and always makes an HTTP request viagetJwks— it never callsjwtPlugin.endpoints.getJwks(ctx)directly and never passesjwksCacheKey.
Inconsistency: Logout always fetches over HTTP, even when the service is running in-process. Introspect and revoke avoid the HTTP round-trip by calling the JWT plugin endpoint directly when no
remoteUrlis configured.
Caching Mechanics#
Caching is implemented in getJwksForVerification (the internal implementation behind getJwks).
Two cache structures#
| Source type | Cache variable | Key |
|---|---|---|
| String (URL) | jwksCache — module-level Map | The URL string |
| Function (in-process) | functionJwksCache — module-level WeakMap | A stable object passed as jwksCacheKey |
For function sources without a jwksCacheKey, the key set is fetched on every call — no caching occurs . Introspect and revoke pass the JWT plugin instance as jwksCacheKey; because the plugin object is stable across requests, the WeakMap entry is reused. Logout uses a URL string, so it benefits only from the Map-based URL cache — not the WeakMap-based plugin-instance optimization.
TTL and cache-miss behavior#
- 5-minute TTL —
JWKS_CACHE_TTL_MS: a cached entry is served until it ages past 5 minutes. kid-miss invalidation —getFreshJwksWithKid: if the JWT header carries akidthat is absent from the cached key set, the cache is bypassed and a fresh fetch is triggered.- No-
kidrefetch cooldown —shouldRefetchCachedJwksWithoutKid: when a token has nokidand signature verification fails against a cached JWKS, one immediate retry is allowed; subsequent retries are rate-limited to once every 30 seconds (JWKS_NO_KID_REFETCH_COOLDOWN_MS) to prevent invalid tokens from hammering the JWKS endpoint.
JWT vs. Opaque Token Dispatch#
Introspect and revoke do not know upfront whether a token is a JWT or opaque. The JWKS verification is attempted first; if it fails with a shape error (e.g., JWSInvalid, TypeError), the endpoint falls back to an opaque DB lookup. JWT-specific errors (JWTExpired, JWTInvalid) resolve to { active: false } per RFC 7009/7662 rather than propagating .
Guard against plain session JWTs#
Because session JWTs and OAuth access tokens share the same issuer, algorithm, and signing key, validateJwtAccessToken requires the azp (authorized party) claim before reporting a token active . Only tokens issued through the OAuth token endpoint carry azp; plain session JWTs do not and are rejected. Revoke applies the same guard — tokens without azp resolve as a silent no-op .
verifyAccessTokenRequest (Resource Server Entry Point)#
For resource servers that need to validate tokens they receive, verifyAccessTokenRequest is the recommended entry point. It wraps verifyAccessTokenPayload, which:
- Attempts local JWS verification via
verifyJwsAccessTokenwhenjwksUrlis provided andremoteVerify.forceis not set. - Falls back to (or forces) remote introspection via
remoteVerify.introspectUrlwhen configured. - Validates
audience,issuer, and optionalrequiredScopesagainst the payload.
verifyBearerToken is a simpler variant that rejects DPoP-bound tokens outright; use verifyAccessTokenRequest when you need to handle both bearer and DPoP-bound tokens.
Key Fix: PR #9987#
PR #9987 (merged 2026-06-11) introduced the current caching model. Before the fix, function-based JWKS sources (in-process calls) had no stable cache key, causing the key set to be re-fetched on every token verification request, and no-kid verification failures could trigger unbounded JWKS refetches. After the fix, jwksCacheKey (the plugin instance) scopes the WeakMap cache and the 30-second cooldown rate-limits no-kid retries.
Key Files#
| File | Role |
|---|---|
packages/core/src/oauth2/verify.ts | Core JWKS caching primitives: getJwksForVerification, verifyJwsAccessToken, verifyAccessTokenRequest |
packages/oauth-provider/src/introspect.ts | Introspection endpoint; validateJwtAccessToken with in-process JWKS fetch + azp guard |
packages/oauth-provider/src/revoke.ts | Revocation endpoint; revokeJwtAccessToken mirrors introspect's JWKS path |
packages/oauth-provider/src/logout.ts | RP-Initiated Logout; verifyLogoutHint with HTTP-only JWKS fetch, no jwksCacheKey |