JWT kid Not Found — Key Rotation Breaks Verification
Fix JWT verification failures during key rotation by caching the JWKS by kid, refreshing on unknown kids, and matching the alg.
Symptoms
- Verification fails with "Unable to find a signing key that matches the kid"
- Tokens fail only after the identity provider rotates keys
- Some tokens verify while others fail during rotation
Possible causes
- The JWKS cache is stale and lacks the new kid
- Tokens are verified with a single hardcoded key instead of selecting by kid
- The signature algorithm does not match the key type (RS256 versus ES256)
Diagnosis steps
Decode the token header
node -e "const t=process.argv[1].split('.')[0]; console.log(JSON.parse(Buffer.from(t,'base64url')))" "$TOKEN"Interpretation
The header alg and kid tell you which key and algorithm the issuer used.
Fetch and inspect the JWKS
curl -s https://idp.example.com/.well-known/jwks.json | jq '.keys[].kid'Interpretation
If the kid in the token is absent from JWKS, the cache is stale and must be refreshed.
Root cause
JWT verification selects a public key by the token kid from the issuer JWKS. When keys rotate, a cached JWKS misses the new kid, so verification cannot find a matching key and fails until the cache refreshes.
Fix
Cache the JWKS and select by kid, refresh the JWKS once on an unknown kid before failing, validate the alg against an allowlist, and keep a short TTL so rotation is picked up quickly.
Risk note
Accepting the token alg blindly enables algorithm-confusion attacks; always verify alg against an allowlist tied to the key.
Verify the fix
Verify a token signed with the rotated key and confirm success; verify a token with an unknown kid triggers one refresh and then fails cleanly.
Symptoms
When you hit “JWT kid Not Found — Key Rotation Breaks Verification”, guessing wastes time. The guide below follows evidence: reproduce the symptom, run targeted diagnostics, identify the root cause, apply the fix, and verify it with concrete checks.
Observed symptoms include:
- Verification fails with “Unable to find a signing key that matches the kid”
- Tokens fail only after the identity provider rotates keys
- Some tokens verify while others fail during rotation
Reproduce and Diagnose
Reproduce the failure in isolation before changing any code. Work through the checks below in order; each one narrows the search space.
Step 1: Decode the token header
node -e "const t=process.argv[1].split('.')[0]; console.log(JSON.parse(Buffer.from(t,'base64url')))" "$TOKEN"
The header alg and kid tell you which key and algorithm the issuer used.
Step 2: Fetch and inspect the JWKS
curl -s https://idp.example.com/.well-known/jwks.json | jq '.keys[].kid'
If the kid in the token is absent from JWKS, the cache is stale and must be refreshed.
Likely contributors, in order of frequency:
- The JWKS cache is stale and lacks the new kid
- Tokens are verified with a single hardcoded key instead of selecting by kid
- The signature algorithm does not match the key type (RS256 versus ES256)
Root Cause
JWT verification selects a public key by the token kid from the issuer JWKS. When keys rotate, a cached JWKS misses the new kid, so verification cannot find a matching key and fails until the cache refreshes.
The Fix
Cache the JWKS and select by kid, refresh the JWKS once on an unknown kid before failing, validate the alg against an allowlist, and keep a short TTL so rotation is picked up quickly.
Watch out: Accepting the token alg blindly enables algorithm-confusion attacks; always verify alg against an allowlist tied to the key.
import { createRemoteJWKSet, jwtVerify } from 'jose';
const jwks = createRemoteJWKSet(new URL('https://idp.example.com/.well-known/jwks.json'));
export async function verify(token) {
const { payload } = await jwtVerify(token, jwks, {
algorithms: ['RS256'], // allowlist, tied to the key
issuer: 'https://idp.example.com/',
audience: 'api.example.com',
});
return payload;
}
// createRemoteJWKSet refreshes automatically on an unknown kid
Verify and Prevent
Verify a token signed with the rotated key and confirm success; verify a token with an unknown kid triggers one refresh and then fails cleanly.
Once verified, add a regression test that reproduces the original symptom so the failure cannot silently return.
This class of failure is usually environmental rather than pure logic, so capture the exact command, the environment variables, and the working directory in your bug report. Reproduce it in the smallest possible script, then widen the scope only once the minimal case is understood. A short written checklist for this failure mode saves hours the next time it appears.