Caching JWKS Without Breaking Key Rotation
Fetching the provider’s key set on every request is not an option — it would make your identity provider a hard dependency of every single API call — and caching it naively turns the next rotation into an outage. This page is part of the validating JWTs and rotating signing keys guide and covers the settings that make the cache both fast and self-healing.
What the Cache Is Actually For
Signature verification is pure CPU, measured in tens of microseconds. The expensive part is obtaining the public key, which is a network call. So the cache exists to remove exactly one thing from the hot path — and nothing else should go in it.
The Three Settings That Matter
A maximum age of five to ten minutes. Short enough that a planned rotation lands without intervention, long enough that the key endpoint is not a hot dependency. Never leave it unbounded: an unbounded cache means the first rotation after a deploy fails every request until the process restarts, and the correlation with the restart is what makes the cause so hard to see.
A refetch when an unknown key identifier arrives. This is what makes rotation invisible rather than merely quick. A token signed with a brand-new key triggers one fetch, the key appears, verification proceeds, and no user notices.
A cooldown on that refetch. Without it, a stream of tokens carrying random key identifiers — from a broken client, a scanner, or an attacker — becomes an outbound request flood against your provider, which will rate-limit you and turn a nuisance into an outage.
import { createRemoteJWKSet } from "jose";
export const JWKS = createRemoteJWKSet(new URL(process.env.JWKS_URI!), {
cacheMaxAge: 10 * 60_000, // bounded staleness: a rotation lands within ten minutes
cooldownDuration: 30_000, // at most one refetch per 30s regardless of unknown kids
timeoutDuration: 3_000, // never let a slow key endpoint hang a request
});
The timeout matters as much as the other two. A key-set fetch with no timeout turns a slow provider into stalled request handlers, and because the fetch happens inside a verification that happens on every request, the failure spreads across your whole surface within seconds.
The Failure Modes, Side by Side
Serverless and Short-Lived Processes
A per-process cache is useless when the process lives for one request, which is the situation in many serverless runtimes. Without care, every invocation fetches the key set, which is both slow and a reliable way to hit a provider’s rate limit under load.
Two approaches work. Keep the cache in a module-level variable and rely on the runtime reusing warm instances, which most platforms do — this alone removes the majority of fetches. For higher volumes, add a shared cache in front: a small key-value entry holding the serialised key set with the same bounded lifetime, refreshed by whichever invocation finds it stale. The shared entry must still be bounded and must still be refetchable on an unknown key identifier, or you have simply moved the staleness somewhere harder to see.
Whatever you choose, pre-warm on start-up rather than lazily on the first user request. A cold start that includes a key-set fetch adds latency to exactly the request a user is waiting for, and a fetch failure at that moment turns a slow response into a failed login.
Monitoring the Cache Itself
Three metrics make rotation problems obvious. Export the set of key identifiers currently cached by each instance, so a fleet where one process is behind stands out immediately. Export the age of the last successful fetch, because a cache that has stopped refreshing looks healthy right up until the rotation. And count refetches triggered by unknown key identifiers: a small spike during a rotation is expected, a steady trickle means something is sending tokens you do not recognise.
Add a synthetic verification that runs on a schedule against a real token, through the same code path as production traffic. It converts “we think caching is fine” into a check that fails within minutes of a problem rather than when a user reports one.
Multiple Issuers and Multi-Tenant Verifiers
A verifier that accepts tokens from several issuers — one per customer in a business-to-business product, or a mix of a primary provider and a legacy one — needs one cache per issuer, keyed by the issuer’s identity rather than a single shared entry.
The mistake to avoid is a global key set assembled from every issuer, because it decouples the key from the issuer that vouched for it: a token claiming one issuer could then be verified with another issuer’s key, which collapses the tenant boundary you were relying on. Resolve the issuer first, look up the configuration and key set registered for it, and verify strictly against that.
Bound the number of issuers you will cache, and require them to be registered rather than discovered from token contents. Otherwise an attacker can drive unbounded cache growth and unbounded outbound fetches simply by presenting tokens naming issuers you have never heard of — the same denial-of-service shape the cooldown prevents, one level up.
Registration also gives you somewhere to record per-issuer expectations — the algorithms you accept from that tenant, the audiences you expect, and when the configuration was last confirmed. Those fields turn a vague “we support customer providers” into something you can audit, and they are what a support engineer needs when one customer’s logins fail and everyone else’s work.
Record when each issuer’s configuration was last confirmed working, too. A per-issuer failure counter is worth adding at the same time: when one tenant’s provider goes down or rotates badly, you want that visible as one customer’s problem rather than as a general rise in verification failures across the fleet.
Where tenants number in the thousands, load key sets lazily with a bounded overall cache and evict the least recently used. The rotation properties are unchanged; you are simply keeping memory finite. What must not change is the strict binding between the issuer in the token and the key set used to verify it.
Frequently Asked Questions
How long should the cache live?
Five to ten minutes, paired with a refetch on an unknown key identifier. Shorter makes the provider’s key endpoint a hot dependency for no benefit, since the refetch already handles new keys. Longer extends the overlap window every rotation must respect and slows recovery when a key is withdrawn in an emergency.
Should I honour the Cache-Control header on the JWKS response?
Use it as an upper bound, not as the sole input. Some providers publish very long lifetimes that would leave you stale through a rotation, and some publish very short ones that would turn the endpoint into a per-request dependency. Clamp whatever you receive into your own range, and keep the unknown-key refetch regardless of what the header says.
Can I cache the fact that a key identifier is unknown?
Only very briefly, and only to protect the provider. A negative cache that outlives a rotation means tokens signed with a legitimately new key are rejected for the duration, which is precisely the outage you were avoiding. If you cache negatives at all, keep it to seconds and treat it as rate limiting rather than as a lookup result.
What should happen if the key endpoint is unreachable?
Keep using the cached keys past their nominal lifetime rather than failing every request — a stale key set still verifies tokens signed by keys that have not changed, and rejecting everything converts a provider blip into a total outage. Cap that grace period, log loudly while it is in effect, and never extend it far enough to keep accepting tokens signed by a key you intended to withdraw.
Does each service need its own cache?
An in-process cache per service is the simplest arrangement and is usually right, since the data is small and public. What matters is that every one of them is bounded and refetch-capable — a fleet is only as current as its stalest member. If you do centralise the cache, make sure the shared layer does not become a single point of failure for verification across every service.
Cache Lifetime Against Recovery Time
The cache is small, boring infrastructure that decides whether a routine provider operation is invisible or an outage, which makes it worth the few settings above.
Related
- Validating JWTs and rotating signing keys — the rotation arithmetic this cache participates in.
- Debugging JWT signature verification failures — diagnosing the failures a stale cache produces.
- Configuring identity providers for OIDC — discovery, and validating where the key set comes from.