Engineering research / security

Verifying JWTs Through a Signing-Key Rotation: JWKS Caching, Unknown kid Refetch, Overlap Windows and Algorithm Pinning

When an issuer rotates its signing key, tokens signed with the new key arrive before your cached keys know about it, and verification fails for valid tokens. This explains the kid header, JWKS caching with a bounded refetch, the overlap window, and the algorithm-confusion trap to avoid.

By Sandeep Belgavi · Published 2026-10-25 · Prepared 2026-10-23

Core engineering · Practical technical guide · 23 October 2026

When an issuer rotates its signing key, tokens signed with the new key arrive before your cached keys know about it, and verification fails for valid tokens. This explains the kid header, JWKS caching with a bounded refetch, the overlap window, and the algorithm-confusion trap to avoid.

Rotation is normal, and it breaks naive verifiers

An identity provider signs its JSON Web Tokens with a private key and publishes the matching public keys at a JWKS endpoint, a JSON document of keys each carrying a key id. A verifier fetches that document, caches it, and uses it to check signatures. Rotation is routine hygiene: the issuer periodically introduces a new signing key and retires the old one. The problem is timing. The moment the issuer starts signing with the new key, tokens carrying the new kid reach your service, but your cached JWKS still lists only the old key. A verifier that treats its cache as complete rejects those tokens as if the signature were forged, and a wave of valid users is locked out until the cache happens to refresh.

The kid header is the hinge

Every JWS carries a header, and a well-behaved issuer puts a kid in it that names which key signed the token. The verifier uses the kid to select the matching key from the JWKS rather than trying each key in turn. That single field is what makes a clean rotation possible: during the overlap window the issuer publishes both the old and new public keys, each token says which one it needs, and a verifier that selects by kid and refetches on an unknown one can verify both old and new tokens throughout the transition.

FieldWhereRole in rotation
kidToken header and JWKNames the signing key so the verifier selects the right public key
algToken header and JWKThe algorithm; must be pinned by the verifier, not trusted from the token
use / key_opsJWKMarks a key for signature verification
cache max-ageJWKS HTTP responseUpper bound on how stale your cached keys may be

Caching with a bounded refetch

The correct cache is not fetch-once and not fetch-every-time. Cache the JWKS and reuse it, but when a token presents a kid the cache does not contain, refetch, so rotation is handled automatically: the first token with the new kid triggers one refetch, the new key enters the cache, and every later token verifies from cache. The piece libraries do not give you for free is a limit on that refetch. In PyJWT, for example, PyJWKClient.get_signing_key refetches the JWKS whenever the cached set has no matching kid, then raises if it still does not match; the fetched set is cached, but a failed lookup is not, so a stream of tokens bearing kids that will never exist refetches the JWKS on every one. You have to add the bound yourself.

Illustrative python, checked for syntax; adapt names and storage to your system.

import jwt
from jwt import PyJWKClient

# PyJWKClient caches the JWKS and refetches when it sees an unknown kid.
# It does NOT rate-limit that refetch, so guard it for untrusted traffic.
jwks = PyJWKClient("https://issuer.example.com/.well-known/jwks.json",
                   cache_keys=True, lifespan=3600, max_cached_keys=16)

_recent_bad_kids = {}            # kid -> last refetch time; a tiny negative cache

def verify(token):
    kid = jwt.get_unverified_header(token).get("kid")
    if kid in _recent_bad_kids and now() - _recent_bad_kids[kid] < 60:
        raise InvalidKid(kid)    # seen and failed recently: do not refetch again
    try:
        signing_key = jwks.get_signing_key_from_jwt(token)   # selects by kid, refetches if unknown
    except Exception:
        _recent_bad_kids[kid] = now()
        raise
    return jwt.decode(
        token,
        signing_key.key,
        algorithms=["RS256"],          # pin the algorithm; never read it from the token
        audience="api://orders",
        issuer="https://issuer.example.com",
        options={"require": ["exp", "iss", "aud"]},
    )

Two settings carry the safety. algorithms is a fixed allowlist, so the verifier decides the algorithm, not the token. The require option forces the standard claims to be present so a token missing an expiry is rejected rather than treated as eternal.

Why not just fetch the JWKS every time

A verifier that fetched the JWKS on every request would never miss a rotation, and it would also turn the issuer JWKS endpoint into a hard dependency on the hot path of every call, add its latency to every verification, and invite rate limiting or an outage to take your service down with it. Caching exists precisely to keep verification local and fast. The unknown-kid refetch is what lets you have both: the cache serves the overwhelming majority of tokens with no network call, and a new key triggers a refresh in the serialized example. Parallel callers need shared caching or request coalescing to avoid duplicate refreshes. The rate limit on that refetch is the safety valve, because a flood of tokens bearing fabricated kids must not be allowed to turn into a flood of outbound JWKS requests that you inflict on the issuer and on yourself; once the limit is reached, the right behaviour is to reject the unverifiable tokens, not to keep fetching.

The overlap window is the issuer half of the contract

Caching solves the verifier side; the issuer must hold up its half. When it rotates, it should publish the new public key in the JWKS before it starts signing with the new private key, and keep the old public key published until every token signed with the old key has expired. That overlap window, at least as long as the maximum token lifetime, is what lets verifiers refetch and catch up without a gap. If the issuer removes the old key too early, tokens still in flight that were signed with it become unverifiable even though they have not expired. A verifier cannot fix a too-short overlap; it can only make the most of a correct one by refetching on unknown kids and respecting the cache max-age the issuer sets.

A worked example

An issuer rotates at noon. Tokens live for one hour. At 11:59 it publishes key B alongside key A in the JWKS. At 12:00 it begins signing with B. A service with a one-hour JWKS cache last fetched at 11:30 still has only A. At 12:00:03 the first token with kid=B arrives; the verifier does not find B, refetches once, now has both A and B, and verifies the token. Every B token after that verifies from cache. Tokens signed with A before noon keep verifying because A is still published. At 13:00, an hour after it stopped signing with A, the issuer removes A, by which time no live token needs it. In this serialized illustrative trace, with a successful refresh and the stated overlap, valid tokens continue to verify. Real deployments also need clock-skew allowance, refresh-failure policy and concurrent-fetch control.

Failure modes

  • Treating the JWKS cache as complete and rejecting an unknown kid instead of refetching once.
  • Refetching on every unknown kid with no rate limit, so bogus tokens become a self-inflicted flood of JWKS requests.
  • Reading alg from the token instead of pinning it, which opens the RS256-to-HS256 confusion where a public key is used as an HMAC secret.
  • An issuer overlap window shorter than the token lifetime, retiring a key while tokens signed with it are still valid.
  • No upper bound on cache lifetime, so a verifier keeps using keys long after the issuer rotated and documented a short max-age.

Verification

Test with a local issuer: sign tokens with key A, verify, then rotate to key B while A is still published and assert both verify. Remove A and assert A-signed tokens now fail while B-signed tokens pass. Add a test that an unknown kid triggers exactly one refetch and that a storm of unknown kids does not exceed the rate limit. Assert that a token whose header claims HS256 is rejected when the verifier pins RS256, and that a token with no exp is rejected. In production, alert on a spike in signature-verification failures that correlates with a rotation; it usually means the cache is not refetching or the issuer shortened the overlap.

Sources and scope

Checked against these sources on 23 October 2026. Examples and figures are illustrative, not measurements.

Cache the JWKS but refetch once, rate-limited, on an unknown kid; select the key by kid; pin the algorithm instead of trusting the token; require the standard claims; and rely on the issuer publishing both keys for an overlap at least as long as the token lifetime.