Skip to content
ecdsa.com
← All articles

JWKS key rotation without downtime

Rotation fails in two ways: verifiers reject tokens signed with a key they have not fetched yet, or keep accepting a key you have already withdrawn. Both are timing problems, and both can be measured. Here is the timeline, the cache behavior of seven verifier libraries, and what four large providers actually send.

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 kid triggers 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 random kid values. 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 kid never 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.mjs (jose 6.2.13, Node.js 24.22)
// 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();
Output (node:24.22.0-bookworm-slim)
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:

  1. 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.
  2. 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.
  3. Removing a key did not revoke it. api2 kept accepting the removed key A until its cache expired. Only the verifier that was told to reload() 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.

StepWhenWhat
1. PublishT₀Add the new key to the set, with a fresh kid, its alg and "use": "sig". Keep signing with the old key.
2. SwitchT₀ + CStart signing with the new key. Every verifier has had a chance to fetch it.
3. RetireT₀ + C + L + SThe last token signed with the old key has expired everywhere.
4. Removeafter step 3, plus C if you want caches to forget itDelete 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 kid keeps 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; the kid arrives 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 without kid therefore breaks exactly during every rotation.
  • Paired with alg. Publish alg on 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:

LibraryCache lifetimeRefresh on unknown kidForced refresh
jose 6.2.13 createRemoteJWKSet10 minutesat most once per 30 sjwks.reload()
jwks-rsa 4.1.010 minutes, per kidevery miss, unless rateLimit: true (then 10 per minute)create a new client
PyJWT 2.15.1 PyJWKClient300 sat most once per 30 sget_jwk_set(refresh=True)
keyfunc v3.8.2 (Go)background refresh every hourat most once per 5 minutesStorage().KeyDelete(ctx, kid) drops one key
Nimbus JOSE+JWT 10.10 JWKSourceBuilder defaults5 minutes, refreshed 30 s aheadat most once per 30 sbuild a new source
Microsoft.IdentityModel 8.23.0 ConfigurationManager (ASP.NET Core)12 hoursat most once per 5 minutesRequestRefresh(), itself limited to once per 5 minutes
go-oidc v3.21.0 RemoteKeySetno expiryevery miss, one fetch in flightrestart

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:

main.go (go-oidc v3.21.0, go-jose v4.1.6, Go 1.27.3)
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")
}
Output (golang:1.27.3-trixie)
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:

headers.sh
#!/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
headers.sh: caching headers and key count (curlimages/curl:8.11.1, 20 Oct 2026)
== 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-age from 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. A max-age of 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:

  1. 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.
  2. 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.
  3. Purge every cache in front of the JWKS endpoint. A CDN that still serves the old set undoes step 2.
  4. Treat everything the key signed as suspect. Revoke refresh tokens and sessions issued during the exposure, and check logs for tokens with the compromised kid that 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, an alg and "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-Control with stale-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