Rotating JWKS Signing Keys Without Locking Everyone Out

·4 min read

jwksjwtkey-rotationoperations

Signing keys need rotating — after a suspected compromise, on a compliance schedule, or just as good hygiene. The mechanism is simple. The sequencing is where it goes wrong, and getting it wrong means every token you issue fails verification everywhere at once.

Why the naive version breaks

The obvious approach: generate a new key, swap it in, publish the new JWKS. Done in a minute.

What actually happens:

t=0    New key K2 replaces K1. JWKS now serves only K2.
t=0    Server starts signing with K2.
t=0    Consumer A has JWKS cached from 20 minutes ago — it knows only K1.
t=1    Token signed with K2 arrives at Consumer A.
       kid=K2 is not in its cache.
       -> verification fails
t=1    Every API call from every consumer with a warm cache fails.

The window lasts until every consumer's cache expires. If someone cached with a one-hour TTL, that's an hour of hard failures — and consumers that fetch JWKS only at process start won't recover until they're restarted.

Two independent mistakes are stacked here: publishing the new key at the same moment you start using it, and removing the old key while tokens signed with it are still alive.

The sequence that works

Rotation needs two waiting periods. Neither is optional.

Step 1 — Publish the new key. Keep signing with the old one.

JWKS now serves both K1 and K2. Nothing has changed about the tokens being issued; you're only giving consumers a chance to learn about K2.

{
  "keys": [
    { "kid": "K1", "kty": "RSA", "use": "sig", "alg": "RS256", "n": "...", "e": "AQAB" },
    { "kid": "K2", "kty": "RSA", "use": "sig", "alg": "RS256", "n": "...", "e": "AQAB" }
  ]
}

Step 2 — Wait for at least one full cache TTL.

Whatever Cache-Control: max-age you serve on the JWKS endpoint, wait longer than that. Every well-behaved consumer will have re-fetched and now knows both keys. This is the step people skip.

Step 3 — Start signing with K2. Keep K1 published.

New tokens carry kid: K2, which consumers already have. Tokens signed with K1 are still in circulation and still verify, because K1 is still in the JWKS.

Step 4 — Wait for the maximum access token lifetime.

Until every K1-signed token has expired. If your access tokens live 15 minutes, wait at least 15 minutes — plus any clock-skew leeway your consumers allow.

Step 5 — Remove K1.

Now nothing in circulation depends on it.

Total elapsed time is cache TTL + max token lifetime, both of which you control. For typical values that's under an hour with zero failed verifications.

Emergency rotation is different

Everything above assumes a planned rotation. If a key is actually compromised, the calculus inverts: you want the old tokens to stop working.

Pull K1 immediately and accept that tokens signed with it fail. That's the point. Have a way for consumers to force a JWKS refresh, and expect a burst of re-authentication.

This is a reason to keep access token lifetimes short. Long-lived tokens make emergency rotation genuinely disruptive, because the blast radius is every token you've issued in the last however-long.

What consumers have to get right

Rotation only works if the verifying side cooperates. Three requirements:

Match on kid, not position. Taking keys[0] works until there are two keys — which is exactly the state rotation puts you in. This is the single most common cause of rotation failure, and it's silent until the day it matters.

Refetch on unknown kid, with a rate limit. If a token arrives with a kid you don't recognize, refetch the JWKS once before rejecting it. This makes consumers self-healing and shrinks the required overlap. Rate-limit it, or a token with a garbage kid becomes a denial-of-service against your own authorization server.

Respect Cache-Control. Caching forever means never learning about new keys. Caching not at all means a JWKS fetch on every request. Honour the header.

Sizing the windows

  • JWKS cache TTL: 5–60 minutes is typical. Shorter means faster rotation and more traffic to the endpoint.
  • Overlap before signing: at least the cache TTL, and add margin for consumers that ignore it.
  • Overlap after signing: at least the max access token lifetime plus clock skew leeway.
  • Keys published at once: two during rotation. More than that usually means a previous rotation didn't finish cleanly.

Checking it

The OIDC Discovery tool fetches a provider's .well-known/openid-configuration and follows it to the JWKS, so you can see exactly which keys are published right now and confirm both are present mid- rotation. Paste a token into the JWT Decoder to read its kid and verify it against the live JWKS — that's the fastest way to confirm a consumer would succeed before you advance a step.

For how AuthAction publishes and rotates keys, see the OAuth2 endpoint reference. If you're verifying tokens in your own API, the backend integration guides cover kid matching per language.


Rotation you don't have to schedule

AuthAction publishes JWKS, overlaps keys during rotation, and retires old ones automatically — no maintenance window.

Free, unlimited users. No credit card required.