Short answer. Publish the new key in your JWKS before you sign anything with it, and wait at least one full verifier cache lifetime. Then switch signing to the new key. Keep the old key published until the last token it signed has expired, plus clock skew, plus one cache lifetime. Give every key a kid that is never reused. For an emergency, remove the key and make verifiers refresh: until they do, the removed key is still trusted, and the cache lifetime of your slowest verifier is the length of that window.
How verifiers find the key
An issuer publishes its public keys as a JWK Set at the jwks_uri listed in its OpenID Connect discovery document or OAuth authorization server metadata. Every token carries a kid in its header. The verifier fetches the set, caches it, and picks the key whose kid matches. OpenID Connect Core describes rotation in four sentences (§10.1.1): the signer adds new keys to the set, starts using one when it wants, signals the change through kid, and the verifier "knows to go back to the jwks_uri location to re-retrieve the keys when it sees an unfamiliar kid value". The set "SHOULD retain recently decommissioned signing keys for a reasonable period of time".
That mechanism has two consequences that the spec leaves implicit:
- An unknown
kidtriggers a fetch, but verifiers rate-limit those fetches. Without a limit, anyone could make your API fetch the JWKS on every request by sending tokens with randomkidvalues. With a limit, a token signed by a key that was not yet published when the verifier last looked is rejected until the limit expires. - A known
kidnever triggers a fetch. Removing a key from the set does nothing until the verifier's cache expires on its own. The refresh-on-miss logic cannot notice a removal, because the removed key is not missing from the cache.
The timeline, measured
The harness below runs an issuer and two verifiers in one process. The issuer serves a JWKS over HTTP; the verifiers use jose's createRemoteJWKSet with a 4-second cache and a 2-second cooldown between kid-miss refreshes. Real deployments use minutes or hours; the shapes are the same.
// Rotation timeline against a live JWKS endpoint, verified with jose's createRemoteJWKSet.
// Durations are shortened (cache 4 s, cooldown 2 s) so the whole run takes seconds.
import { createServer } from "node:http";
import { SignJWT, createRemoteJWKSet, exportJWK, generateKeyPair, jwtVerify } from "jose";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const t0 = Date.now();
const log = (msg) => console.log(`t=${((Date.now() - t0) / 1000).toFixed(1).padStart(4)}s ${msg}`);
// Issuer: three keys, the currently published kids, and a count of JWKS fetches.
const keys = {};
for (const kid of ["2026-10-a", "2026-11-b", "2026-12-c"]) {
const pair = await generateKeyPair("ES256");
keys[kid] = { ...pair, jwk: { ...(await exportJWK(pair.publicKey)), kid, alg: "ES256", use: "sig" } };
}
let published = ["2026-10-a"];
let fetches = 0;
const server = createServer((req, res) => {
fetches++;
res.setHeader("content-type", "application/jwk-set+json");
res.setHeader("cache-control", "public, max-age=4");
res.end(JSON.stringify({ keys: published.map((kid) => keys[kid].jwk) }));
}).listen(8090);
const sign = (kid) =>
new SignJWT({ sub: "user-42" }).setProtectedHeader({ alg: "ES256", kid }).setExpirationTime("10m").sign(keys[kid].privateKey);
// Two verifiers with identical settings; only the first one is told to reload.
const remote = () =>
createRemoteJWKSet(new URL("http://127.0.0.1:8090/jwks.json"), { cacheMaxAge: 4000, cooldownDuration: 2000 });
const verifiers = { api1: remote(), api2: remote() };
const verify = async (name, label, token) => {
const before = fetches;
let result = "accepted";
try {
await jwtVerify(token, verifiers[name], { algorithms: ["ES256"] });
} catch (e) {
result = e.code;
}
log(`${name} ${label.padEnd(40)} ${result} (fetches +${fetches - before})`);
};
const tokenA = await sign("2026-10-a");
await verify("api1", "token A, set {A}", tokenA);
log("--- planned rotation: publish B, wait one cache lifetime, then sign with B");
published = ["2026-10-a", "2026-11-b"];
await sleep(4200);
await verify("api1", "token A after the cache expired", tokenA);
await verify("api1", "token B", await sign("2026-11-b"));
log("--- mistake: sign with C before publishing it");
await sleep(2200);
const tokenC = await sign("2026-12-c");
await verify("api1", "token C, C not published", tokenC);
published = ["2026-10-a", "2026-11-b", "2026-12-c"];
log("issuer publishes C");
await verify("api1", "token C, inside the cooldown", tokenC);
await sleep(2200);
await verify("api1", "token C, after the cooldown", tokenC);
log("--- emergency: A is compromised and removed");
await verify("api2", "token A before removal", tokenA);
published = ["2026-11-b", "2026-12-c"];
await verify("api1", "token A right after removal", tokenA);
const before = fetches;
await verifiers.api1.reload();
log(`api1 reload() (fetches +${fetches - before})`);
await verify("api1", "token A after reload()", tokenA);
for (let i = 0; i < 4; i++) {
await sleep(1100);
await verify("api2", "token A, no reload", tokenA);
}
server.close();
t= 0.0s api1 token A, set {A} accepted (fetches +1)
t= 0.0s --- planned rotation: publish B, wait one cache lifetime, then sign with B
t= 4.2s api1 token A after the cache expired accepted (fetches +1)
t= 4.3s api1 token B accepted (fetches +0)
t= 4.3s --- mistake: sign with C before publishing it
t= 6.5s api1 token C, C not published ERR_JWKS_NO_MATCHING_KEY (fetches +1)
t= 6.5s issuer publishes C
t= 6.5s api1 token C, inside the cooldown ERR_JWKS_NO_MATCHING_KEY (fetches +0)
t= 8.7s api1 token C, after the cooldown accepted (fetches +1)
t= 8.7s --- emergency: A is compromised and removed
t= 8.7s api2 token A before removal accepted (fetches +1)
t= 8.7s api1 token A right after removal accepted (fetches +0)
t= 8.7s api1 reload() (fetches +1)
t= 8.7s api1 token A after reload() ERR_JWKS_NO_MATCHING_KEY (fetches +0)
t= 9.8s api2 token A, no reload accepted (fetches +0)
t=10.9s api2 token A, no reload accepted (fetches +0)
t=12.0s api2 token A, no reload accepted (fetches +0)
t=13.1s api2 token A, no reload ERR_JWKS_NO_MATCHING_KEY (fetches +1)
Three things happened that will happen in production:
- Planned rotation worked with zero failures, because B was in the verifier's cache before the first token signed with B arrived. No kid-miss refresh was needed.
- Signing before publishing failed, and kept failing after the fix. The verifier refreshed on the unknown
kid, did not find C, and then refused to refresh again during the cooldown, even though C had been published by then. With jose's default cooldown of 30 seconds that is half a minute of rejected requests per verifier instance; with keyfunc's default of 5 minutes it is five. The error the API logs is no applicable key found in the JSON Web Key Set, which looks exactly like a forged token. - Removing a key did not revoke it.
api2kept accepting the removed key A until its cache expired. Only the verifier that was told toreload()stopped at once.
Rules for a zero-downtime rotation
Let C be the longest time any verifier, or any cache in front of the JWKS endpoint, may hold the set; L the longest lifetime of a token you sign; and S the clock skew verifiers allow.
| Step | When | What |
|---|---|---|
| 1. Publish | T₀ | Add the new key to the set, with a fresh kid, its alg and "use": "sig". Keep signing with the old key. |
| 2. Switch | T₀ + C | Start signing with the new key. Every verifier has had a chance to fetch it. |
| 3. Retire | T₀ + C + L + S | The last token signed with the old key has expired everywhere. |
| 4. Remove | after step 3, plus C if you want caches to forget it | Delete the old key from the set and destroy its private half. |
A worked example: a provider whose verifiers include ASP.NET Core APIs (metadata refreshed every 12 hours by default, see below), an Nginx cache in front of the JWKS with a one-hour lifetime, access tokens valid for one hour, and 5 minutes of skew. C is 13 hours, so the new key goes into the set on Monday morning and starts signing Monday evening. The old key can leave on Tuesday morning. The window is a day, not a minute, and the only cost of making it longer is one extra key in the set.
If verifiers are configured with a static key instead of the JWKS URL, there is no C to wait for; the rotation needs a coordinated config change on every verifier, which is why a JWKS URL is the default in every OpenID Connect library.
Rules for kid
- Unique and never reused. RFC 7517 §4.5 says keys in one set "SHOULD use distinct "kid" values". Reuse across time is worse than duplication within a set: a verifier that caches by
kidkeeps the old key under that name, and jose only refreshes when no key matches, not when a matching key fails to verify. Signatures from the new key then fail until the cache expires. - Meaningless to verifiers. Use a random value, a date label such as
2026-11-b, or the RFC 7638 JWK thumbprint. Do not encode anything verifiers should trust; thekidarrives in an unverified header. - Always present. Without a
kid, a verifier must try every key or give up. jwks-rsa refuses when the set has more than one key (No KID specified and JWKS endpoint returned more than 1 key), and jose fails with multiple matching keys found in the JSON Web Key Set. A JWKS with two keys and tokens withoutkidtherefore breaks exactly during every rotation. - Paired with
alg. Publishalgon each key so verifiers bind the key to one algorithm, as RFC 8725 §3.1 requires.
What verifier libraries actually do
The C in the table above is set by the verifiers, not by you. For verifiers written by hand, Microsoft's signing key rollover guidance is a sound default: cache keys individually by kid for 24 hours, refresh every hour in the background, and refresh on an unknown kid, but not more often than every 5 minutes. The library defaults below come from each library's source or a program printing its constants, in the versions listed:
| Library | Cache lifetime | Refresh on unknown kid | Forced refresh |
|---|---|---|---|
jose 6.2.13 createRemoteJWKSet | 10 minutes | at most once per 30 s | jwks.reload() |
| jwks-rsa 4.1.0 | 10 minutes, per kid | every miss, unless rateLimit: true (then 10 per minute) | create a new client |
PyJWT 2.15.1 PyJWKClient | 300 s | at most once per 30 s | get_jwk_set(refresh=True) |
| keyfunc v3.8.2 (Go) | background refresh every hour | at most once per 5 minutes | Storage().KeyDelete(ctx, kid) drops one key |
Nimbus JOSE+JWT 10.10 JWKSourceBuilder defaults | 5 minutes, refreshed 30 s ahead | at most once per 30 s | build a new source |
Microsoft.IdentityModel 8.23.0 ConfigurationManager (ASP.NET Core) | 12 hours | at most once per 5 minutes | RequestRefresh(), itself limited to once per 5 minutes |
go-oidc v3.21.0 RemoteKeySet | no expiry | every miss, one fetch in flight | restart |
Spring Security 7.1.1 builds its NimbusJwtDecoder source with JWKSourceBuilder but switches off refresh-ahead and rate limiting (.refreshAheadCache(false), .rateLimited(false) in JwkSetUriJwtDecoderBuilder), so the 5-minute cache is all that remains of those defaults.
The last row is the outlier. go-oidc keeps the key set until a token fails to verify against it; nothing expires on a timer. Here is what that means for an emergency removal:
package main
// Emergency removal against go-oidc's RemoteKeySet: the key set has no time-based expiry,
// so a removed key keeps verifying until the process restarts or a kid miss forces a fetch.
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/json"
"fmt"
"net"
"net/http"
"sync/atomic"
"time"
"github.com/coreos/go-oidc/v3/oidc"
jose "github.com/go-jose/go-jose/v4"
)
func main() {
keyA, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
keyB, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
jwkA := jose.JSONWebKey{Key: &keyA.PublicKey, KeyID: "2026-10-a", Algorithm: "ES256", Use: "sig"}
jwkB := jose.JSONWebKey{Key: &keyB.PublicKey, KeyID: "2026-11-b", Algorithm: "ES256", Use: "sig"}
var published atomic.Value
published.Store([]jose.JSONWebKey{jwkA, jwkB})
var fetches atomic.Int32
ln, _ := net.Listen("tcp", "127.0.0.1:8092")
go http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fetches.Add(1)
json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: published.Load().([]jose.JSONWebKey)})
}))
sign := func(key *ecdsa.PrivateKey, kid string) string {
signer, _ := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: key},
(&jose.SignerOptions{}).WithHeader("kid", kid))
claims, _ := json.Marshal(map[string]any{
"iss": "https://id.example.com", "aud": "app", "sub": "user-42",
"exp": time.Now().Add(10 * time.Minute).Unix(),
})
jws, _ := signer.Sign(claims)
out, _ := jws.CompactSerialize()
return out
}
tokenA := sign(keyA, "2026-10-a")
ctx := context.Background()
keySet := oidc.NewRemoteKeySet(ctx, "http://127.0.0.1:8092/jwks.json")
verifier := oidc.NewVerifier("https://id.example.com", keySet, &oidc.Config{
ClientID: "app", SupportedSigningAlgs: []string{"ES256"},
})
start := time.Now()
check := func(label string) {
before := fetches.Load()
_, err := verifier.Verify(ctx, tokenA)
result := "accepted"
if err != nil {
result = err.Error()
}
fmt.Printf("t=%4.1fs %-34s %s (JWKS fetches +%d)\n", time.Since(start).Seconds(), label, result, fetches.Load()-before)
}
check("token A, set {A,B}")
published.Store([]jose.JSONWebKey{jwkB})
fmt.Printf("t=%4.1fs A removed from the set\n", time.Since(start).Seconds())
check("token A right after removal")
time.Sleep(5 * time.Second)
check("token A 5 s later")
// Any token with an unknown kid makes RemoteKeySet fetch the set again.
other, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
before := fetches.Load()
_, err := verifier.Verify(ctx, sign(other, "unknown-kid"))
fmt.Printf("t=%4.1fs %-34s %v (JWKS fetches +%d)\n", time.Since(start).Seconds(), "token with an unknown kid", err, fetches.Load()-before)
check("token A after that refetch")
}
t= 0.0s token A, set {A,B} accepted (JWKS fetches +1)
t= 0.0s A removed from the set
t= 0.0s token A right after removal accepted (JWKS fetches +0)
t= 5.0s token A 5 s later accepted (JWKS fetches +0)
t= 5.0s token with an unknown kid failed to verify signature: failed to verify id token signature (JWKS fetches +1)
t= 5.0s token A after that refetch failed to verify signature: failed to verify id token signature (JWKS fetches +1)
The removed key stayed trusted until something else caused a fetch. In a quiet service that can be days. go-oidc also refetches on every failed verification without a rate limit, only collapsing concurrent fetches into one. The same failure surfaces as failed to verify id token signature, whether the cause is a forged token, a key not yet published, or one already removed.
None of jose, jwks-rsa, PyJWT or keyfunc reads the Cache-Control header of the JWKS response; their source has no reference to it. The verifier's own setting is what counts, and you cannot change it from the issuer side.
Cache-Control on the JWKS endpoint
Cache-Control still matters, for CDNs and reverse proxies in front of the endpoint and for verifiers that follow the provider's advice. Google, for example, tells developers to "examine the Cache-Control header in the response to determine when you should retrieve them again" (Google Identity). What four large issuers and one sample tenant sent on 20 October 2026, fetched with this script:
#!/bin/sh
# Prints the caching headers and the number of keys of public JWKS endpoints.
for url in \
https://www.googleapis.com/oauth2/v3/certs \
https://login.microsoftonline.com/common/discovery/v2.0/keys \
https://appleid.apple.com/auth/keys \
https://token.actions.githubusercontent.com/.well-known/jwks \
https://samples.auth0.com/.well-known/jwks.json
do
echo "== $url"
curl -s -D - -o /tmp/body.json "$url" | grep -iE '^(cache-control|expires):' | tr -d '\r'
echo "keys: $(grep -o '"kid"' /tmp/body.json | wc -l)"
done
== https://www.googleapis.com/oauth2/v3/certs
expires: Tue, 20 Oct 2026 17:32:23 GMT
cache-control: public, max-age=21671, must-revalidate, no-transform
keys: 2
== https://login.microsoftonline.com/common/discovery/v2.0/keys
Cache-Control: max-age=86400, private
keys: 7
== https://appleid.apple.com/auth/keys
Cache-Control: no-store
keys: 3
== https://token.actions.githubusercontent.com/.well-known/jwks
cache-control: public, max-age=3600, must-revalidate
keys: 4
== https://samples.auth0.com/.well-known/jwks.json
cache-control: public, max-age=15, stale-while-revalidate=15, stale-if-error=86400
keys: 2
The script prints only the Cache-Control and Expires headers and counts the kid members in the body. The range runs from "never store" (Apple) to a day (Microsoft), and every issuer had more than one key published, which is what a set looks like when rotation is routine. The answers also vary between requests: half an hour earlier Microsoft's endpoint listed eight keys and Google's max-age was 22930 seconds, so a verifier that honors max-age gets a different cache lifetime on every fetch.
For your own endpoint:
- Pick
max-agefrom your rotation window, not the other way round. Whatever you send, a shared cache will hold the set that long, and a kid-miss refresh that goes through that cache gets the stale copy. Amax-ageof an hour means step 2 above must wait at least an hour. - Add
stale-if-error. If your endpoint is down, verifiers and CDNs that support it (RFC 5861) keep serving the last good set instead of failing every request. Auth0's sample tenant does exactly this. - Never let the endpoint return an empty set or an error page with status 200. jwks-rsa then fails with The JWKS endpoint did not contain any signing keys, and jose with Expected 200 OK from the JSON Web Key Set HTTP response on a non-200 response.
Emergency removal
When a signing key leaks, the priority flips: the goal is no longer zero failed requests but the shortest time during which forged tokens are accepted. The order:
- Publish a set without the compromised key, with a new key already in it, and switch signing to the new key at once. Tokens signed by the new key will be rejected by some verifiers until their kid-miss refresh runs; that is the price.
- Force verifiers to refresh. Call the forced-refresh method from the table above, or restart the processes. For go-oidc, a restart is the only reliable way. If you cannot reach every verifier, their cache lifetime is your exposure window: 12 hours for ASP.NET Core defaults, unbounded for go-oidc.
- Purge every cache in front of the JWKS endpoint. A CDN that still serves the old set undoes step 2.
- Treat everything the key signed as suspect. Revoke refresh tokens and sessions issued during the exposure, and check logs for tokens with the compromised
kidthat your issuer did not mint.
Practice this. An emergency rotation is a planned rotation with the waiting removed; if planned rotations are automated and run every few weeks, the emergency path is a known script rather than an improvisation. NIST SP 800-57 Part 1 Rev. 5 §5.3.6 suggests a maximum cryptoperiod of "about one to three years" for a private signature key; that is a ceiling, not a target.
Checklist
- Every key in the set has a unique
kid, analgand"use": "sig"; no private members, no symmetric keys. - Tokens always carry a
kid. - New keys are published at least C before they sign; old keys stay at least L + S + C after the last signature.
- The JWKS endpoint sends a deliberate
Cache-Controlwithstale-if-error, never returns an empty set with 200, and its URL never changes. - Every verifier you operate has a known cache lifetime and a known way to force a refresh.
- The emergency procedure is written down and has been run.
Sources
- OpenID Connect Core 1.0 §10.1.1: rotation of asymmetric signing keys
- RFC 7517: JSON Web Key, §4.5 (
kid) and §5 (JWK Set) - RFC 7638: JWK thumbprint
- RFC 8725: JWT Best Current Practices
- RFC 5861:
stale-while-revalidateandstale-if-error - Microsoft identity platform: signing key rollover
- Google Identity: verify the Google ID token
- NIST SP 800-57 Part 1 Rev. 5, §5.3.6