When JWT verification starts failing around a signing-key change, I treat it as a distributed key-lifecycle and cache problem, not just an invalid signature problem. A successful rotation has to coordinate key publication, signing cutover, verifier cache behavior, token lifetime, and eventual retirement of the old key.
If I have a token and the issuer's trusted key set, I start with the JWKS Validator and compare kid, signature result, and claim lifetime.
The safe sequence is publish before sign
For a planned asymmetric rotation from K1 to K2, the sequence I want is:
1. Publish public K2 in trusted JWKS while K1 remains.
2. Give verifiers time/a mechanism to learn K2.
3. Start signing new JWTs with private K2 / kid=K2.
4. Continue publishing public K1 for still-valid K1 tokens.
5. Retire K1 only when policy says old tokens no longer need it.
The dangerous order is “start signing K2, publish K2 later.” Every verifier with a K1-only cache will see an unknown kid and fail.
I inspect four values before touching cache settings
From the token:
{
"alg": "RS256",
"kid": "K2"
}
and payload:
{
"iss": "https://login.example.com/",
"iat": 1789200000,
"exp": 1789203600
}
I want:
- expected issuer;
kid;- issue time;
- expiration time.
Those tell me which generation signed the token and how long the corresponding public key may still be needed.
A rotation timeline exposes failures better than a static JWKS dump
t0 JWKS = [K1]
t1 publish K2 -> JWKS = [K1, K2]
t2 begin signing K2
t3 old K1 tokens still arrive
t4 last acceptable K1 token expires
t5 remove K1 according to policy
If failures begin at t2, I inspect how fast verifiers learn K2. If failures begin at t5, I inspect whether K1 was removed before all required old tokens aged out.
Unknown kid should trigger a controlled refresh, not blind trust
A useful verifier strategy is:
lookup kid in cache
found -> verify
missing
↓
refresh from already trusted jwks_uri
↓
lookup again
still missing -> reject
The refresh must remain bound to the issuer's trusted JWKS location. I do not let arbitrary JWT header URLs redefine where keys come from.
I prevent an unknown-kid thundering herd
If thousands of requests arrive with the same new kid, a naive implementation can make every worker fetch JWKS simultaneously.
I prefer controls such as:
- single-flight/deduplicated refresh per issuer;
- short backoff after failed refresh;
- rate limiting;
- normal HTTP cache semantics (
Cache-Control, validators such as ETag when supported); - metrics for refreshes and unknown-kid events.
I also avoid long negative caching of an unknown kid, because that can prolong an outage immediately after a legitimate rotation.
Planned rotation and emergency revocation have different goals
For planned rotation I usually want continuity. For a compromised key I may intentionally want old K1 tokens to stop verifying before their exp.
| Scenario | Old public key behavior |
|---|---|
| planned rollover | retain long enough for intended token acceptance window |
| suspected key compromise | revoke/retire according to incident policy, even if sessions break |
| issuer migration | explicit migration rules; do not mix issuers casually |
There is no universal “always keep old key until every token expires” rule for emergency events.
How I estimate the planned overlap window
I start from application facts, not an arbitrary number:
old-key publication overlap
>= maximum remaining lifetime of K1-signed tokens
+ small clock/policy buffer as required by the system
I also make sure K2 is published before K2 signing by enough time or with an unknown-kid refresh mechanism that consumers actually implement.
Cache TTL affects how quickly new keys are learned; token lifetime affects how long old keys remain necessary. They solve different halves of rotation.
Stale JWKS cache has recognizable symptoms
Examples:
new tokens (kid=K2) fail
old tokens (kid=K1) pass
issuer JWKS already contains K2
That strongly suggests a verifier still holds a K1-only cache.
The opposite:
new K2 tokens pass
old K1 tokens suddenly fail
current JWKS contains only K2
old token exp is still in future
suggests K1 may have been removed before the intended overlap ended.
Duplicate kid is an ambiguity problem
A kid is a selection identifier, not a globally unique cryptographic identity. If a JWKS contains multiple applicable keys with the same kid, I do not silently choose the first one.
I filter kty, use, key_ops, alg, and curve where relevant, and reject remaining ambiguity. The JWT kid guide covers this selection layer.
Issuer binding still matters during rotation
My trust path remains:
expected issuer
↓
trusted discovery/metadata
↓
trusted jwks_uri
↓
K1/K2 candidates
↓
kid + metadata filtering
↓
signature verification
A K2 with the right kid from the wrong tenant is not a successful rotation.
I test both old and new tokens before declaring the rollout healthy
My rollout test set includes:
old K1 token, still unexpired -> should follow planned old-key policy
new K2 token -> must verify
unknown kid -> one controlled refresh, then reject if absent
bad signature with K2 kid -> reject
wrong issuer with same kid -> reject
That catches more than checking whether /jwks.json contains two objects.
I monitor rotation as an operational event
Useful metrics/log fields include:
- JWT verification failure rate by reason;
- unknown
kidcount; - JWKS refresh count and latency;
- cache age;
- selected issuer and
kid(without logging whole tokens); - ratio of K1 vs K2 tokens during transition;
- last successful JWKS refresh.
A rotation outage becomes much easier to diagnose when I can see “K2 unknown on 40% of instances” instead of a generic spike in 401s.
Key rotation decision matrix
| Symptom | Likely failure | Next check |
|---|---|---|
| K2 tokens fail, K1 passes | stale verifier cache / K2 published too late | trusted JWKS + refresh behavior |
| K1 tokens fail, K2 passes | K1 retired too early | old-token lifetime/policy |
| only one instance fails | per-instance cache divergence | cache age/refresh logs |
| every unknown kid causes many fetches | thundering herd | single-flight/rate limit |
| same kid selects wrong key | ambiguity | kty/alg/use/key_ops/issuer |
| both K1 and K2 signatures fail | wrong issuer/key source | trust path |
My production rotation workflow
- Determine maximum token lifetime and incident policy.
- Publish K2 public material in the issuer's trusted JWKS before using K2 to sign.
- Confirm consumers can see K2 or refresh safely on unknown
kid. - Begin issuing K2-signed tokens while K1 remains available for planned overlap.
- Verify representative K1 and K2 tokens with the JWKS Validator.
- Watch unknown-kid, signature-failure, cache-age, and refresh metrics.
- Keep K1 available for the intended old-token acceptance window in a planned rollover.
- Treat emergency compromise as a different policy that may intentionally invalidate sessions.
- Remove K1 only when the selected policy says it is safe.
- Keep algorithm, issuer, audience, and signature checks enabled throughout the rollout.
JWT key rotation is not merely “generate a new key.” It is a coordinated distributed transition where new keys must become discoverable before use and old keys must remain verifiable for exactly as long as policy requires — no longer, no shorter.