Short answer: Use a split JWT verification architecture: bounded JWKS caching for ordinary API calls, then session introspection only when current server-side state matters—account recovery, credential changes, and other high-risk transitions. For a media product wiring Google and GitHub sign-in, that split keeps the feed fast without pretending a valid JWT proves that an account link is still safe. A reader can lose access to one social identity, connect another, or ask support to restore an account while an older access token remains cryptographically valid. Signature verification answers who issued that token and whether its claims pass policy. It does not, by itself, answer whether the application has since revoked the session or changed the identity link. This distinction is easy to bury under config. Don't. Put it in the gateway policy, benchmark the two paths separately, and make the slow path earn its network hop. How should a JWT verification architecture balance JWKS caching and session introspection? Start with two questions for every route: can a stale authorization decision cause an irreversible action, and must a change made after token issuance take effect before the token expires? If both answers are no, local verification is usually the clean boundary. Validate the signature, allowed algorithm, issuer, audience, expiry, and any application claims the route requires. The OpenID Connect discovery document supplies the issuer's jwks_uri; the key set at that URI supplies public verification keys. When either answer is yes, local verification is necessary but may not be sufficient. RFC 7662 defines token introspection as an authorization-server response about a token's current active state and metadata. A system can therefore perform local JWT checks first and ask the authorization server for live state only at a sensitive boundary. The authorization server has to support introspection for the token in question; this isn't a property that the JWT format creates automatically. For the media example, normal article reads, feed pagination, and draft autosaves can stay on the local path after route-specific authorization. Changing the recovery email, linking a new Google or GitHub identity, unlinking the last usable identity, exporting an account, or completing a support-assisted recovery belongs on the live-state path. Those actions can alter who controls the account. An extra lookup is defensible there. The catch is availability. Introspection adds latency and makes a remote authorization service part of the request path. It also creates a client credential to store and rotate. Local verification avoids that dependency for each request, but its revocation freshness is bounded by token lifetime and whatever state the application checks independently. There is no universal cache duration hiding in the standards. Measure key-rotation behavior, request volume, acceptable revocation delay, and issuer guidance, then set the policy. I'm not sure a fashionable five-minute default tells you anything without those inputs. The recovery constraint changes the design Account recovery is a state machine, not a login button. A successful Google or GitHub callback establishes evidence about a provider identity. The application still owns the mapping from that identity to a local media account, the set of recovery methods, and the rule preventing the last viable sign-in path from being removed accidentally. Keep that mapping behind one internal authorization boundary; don't let each API service reinterpret provider claims. A useful gateway decision record has four outcomes: allow from local claims, require current session state, reject, or step up through a fresh authentication ceremony. That is small enough to test. It also exposes a common category error: introspection and step-up authentication aren't interchangeable. An active token can still be too old or too weak for an account takeover-sensitive operation. OWASP recommends reauthentication after high-risk events and for critical actions, with session invalidation and token rotation where appropriate. Here is the policy I would review before looking at any library: Gateway action Local JWT verification Live session check Fresh user ceremony Read a published story Required No No Save a draft Required Usually no Based on content policy Link a second social identity Required Yes Yes Unlink a recovery identity Required Yes Yes Complete support recovery Required Yes Yes, through the recovery flow “Usually” is deliberate. Your threat model may vary. A newsroom handling embargoed investigations can classify draft access more aggressively than a public blogging product, and that decision belongs in policy rather than in a JWKS helper's defaults. The failure mode worth testing is a timeline: issue token A, revoke or supersede its server-side session, rotate a signing key, then attempt each route with token A. The expected result differs by route. A feed request may remain valid until token expiry if that is the declared policy; an identity unlink should observe the current session and stop. Write those expectations down before tuning caches. The smallest gateway implementation The code below keeps configuration narrow. jose verifies the JWT against a remote JWK Set and caches fetched keys; its remote-set options make cache age, refresh cooldown, and network timeout explicit. The live check is injected as a second decision, so route policy—not a hidden middleware side effect—chooses when to call it. import { createRemoteJWKSet, jwtVerify } from "jose"; type Risk = "ordinary" | "recovery"; type Introspection = { active: boolean; sub?: string; exp?: number; }; type AuthContext = { subject: string; sessionActive: boolean | null; }; const issuer = new URL(process.env.OIDC_ISSUER!); const audience = process.env.API_AUDIENCE!; const introspectionUrl = new URL(process.env.INTROSPECTION_URL!); const jwks = createRemoteJWKSet( new URL(".well-known/jwks.json", issuer), { cacheMaxAge: 10 * 60_000, cooldownDuration: 30_000, timeoutDuration: 2_000, }, ); async function introspect(token: string): Promise<Introspection> { const credentials = Buffer.from( `${process.env.INTROSPECTION_CLIENT_ID!}:${process.env.INTROSPECTION_CLIENT_SECRET!}`, ).toString("base64"); const response = await fetch(introspectionUrl, { method: "POST", headers: { authorization: `Basic ${credentials}`, "content-type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ token }), signal: AbortSignal.timeout(2_000), }); if (!response.ok) throw new Error(`Introspection failed: ${response.status}`); return (await response.json()) as Introspection; } export async function authorize( token: string, risk: Risk, ): Promise<AuthContext> { const verified = await jwtVerify(token, jwks, { issuer: issuer.href.replace(/\/$/, ""), audience, algorithms: ["RS256"], }); if (!verified.payload.sub) throw new Error("Missing subject"); if (risk === "ordinary") { return { subject: verified.payload.sub, sessionActive: null }; } const current = await introspect(token); if (!current.active || current.sub !== verified.payload.sub) { throw new Error("Session is not active for this subject"); } return { subject: verified.payload.sub, sessionActive: true }; } Treat the numeric values as an example configuration, not an industry prescription. The 10-minute key cache is not a 10-minute session cache. They answer different questions. Key selection should also tolerate normal signing-key overlap: cache by issuer and kid, refresh when an unfamiliar kid appears subject to a bounded cooldown, and keep accepting a still-published old key while tokens signed with it remain valid. Pin allowed algorithms in verification policy rather than trusting the token header to choose one. Fail closed on a recovery action when live state cannot be established. For ordinary routes, choose deliberately between continuing with valid local claims and rejecting during a JWKS refresh problem; document the choice per route. Never silently turn an introspection timeout into an approval. Log a low-cardinality reason such as jwt_expired, unknown_kid, bad_audience, inactive_session, or live_check_unavailable, but don't log raw tokens. Benchmark cold-key fetches, warm local verification, and live checks as separate distributions. A single average hides the exact tail that users hit after a deploy or key rotation. Track cache hits, unknown-key refreshes, verification rejections by reason, introspection latency, and step-up outcomes. No config sprawl. Five meaningful counters beat a dashboard full of middleware internals. What should change when the gateway scales? At higher request volume, I would keep the authorization decision centralized but avoid turning one gateway process into the only key cache. Each verifier can maintain a bounded in-memory cache, while deployment waves and key-rotation drills prove that cold instances recover correctly. Shared caching can reduce duplicate fetches, but it adds another consistency and availability boundary. Use it only after measurements show key fetch pressure is real. I would also separate session state from identity-link state. Introspection can say that a token is active; the account service must still decide whether the Google or GitHub identity is currently linked, whether unlinking it would strand the user, and whether a recovery hold is active. For the most sensitive transitions, pass a short-lived, purpose-bound result from a fresh ceremony rather than stretching a general access token into proof it was never designed to carry. Three common product shapes illustrate the deployment trade-off without changing the architecture. Auth0 publishes tenant signing keys and rotation behavior as a managed service boundary. Okta scopes discovery, keys, and introspection to an authorization-server boundary. Keycloak exposes those capabilities within a self-hosted realm boundary. The first two move more operation to a managed control plane; the third gives the team more direct operational control. Stick with a managed boundary when the team doesn't want to run the authorization service. Choose self-hosting when control over deployment and identity data justifies upgrades, monitoring, and incident ownership. None of those choices removes the need to model account recovery in the media application itself. The final trade-off is blunt: local JWT verification optimizes the common path, while live session introspection narrows the window in which a server-side state change can be ignored. A gateway should use both according to consequence, then require a fresh user ceremony where token activity still isn't strong enough evidence. For social sign-in recovery, the winning design is the one that makes identity-control transitions explicit and testable—not the one with the most authentication knobs. References https://www.rfc-editor.org/rfc/rfc7519 https://www.rfc-editor.org/rfc/rfc7662 https://openid.net/specs/openid-connect-discovery-1_0.html https://openid.net/specs/openid-connect-core-1_0.html https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html https://github.com/panva/jose/blob/main/docs/jwks/remote/functions/createRemoteJWKSet.md https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-key-sets https://developer.okta.com/docs/reference/api/oidc/ https://www.keycloak.org/securing-apps/oidc-layers