Key rotation is a security hygiene requirement. An RS256 signing key that signs every access token issued by your system is a high-value target. Rotating keys periodically limits the window of exposure if a key is ever compromised — a rotated key no longer signs new tokens, and its previously-signed tokens will expire naturally. The challenge is doing this without invalidating tokens that were legitimately issued with the old key and have not yet expired. The JWKS protocol solves this cleanly.
How the kid header enables multiple concurrent keys
A JWT protected header can include a kid (key ID) field that identifies which key was used to sign the token. When verifiers retrieve the JWKS endpoint, they receive all currently-valid public keys, each with a corresponding kid. To verify a token, the verifier reads the kid from the token header, finds the matching key in the JWKS response, and verifies the signature with that specific key. Multiple keys can be active simultaneously.
// Issue a JWT with kid in the protected header
import { SignJWT, importPKCS8 } from 'jose';
async function signAccessToken(payload: object, keyRecord: KeyRecord): Promise<string> {
const privateKey = await importPKCS8(keyRecord.privateKeyPem, 'RS256');
return new SignJWT(payload as Record<string, unknown>)
.setProtectedHeader({
alg: 'RS256',
kid: keyRecord.id, // The key identifier
})
.setIssuedAt()
.setExpirationTime('1h')
.sign(privateKey);
}
// Expose the JWKS endpoint
app.get('/.well-known/jwks.json', async (req, res) => {
const activeKeys = await db.signingKeys.findMany({
where: { status: { in: ['active', 'retiring'] } },
orderBy: { createdAt: 'desc' },
});
const jwks = {
keys: activeKeys.map(key => ({
kty: 'RSA',
use: 'sig',
alg: 'RS256',
kid: key.id,
n: key.publicKeyN, // RSA modulus, base64url
e: key.publicKeyE, // RSA exponent, base64url
})),
};
res.setHeader('Cache-Control', 'public, max-age=3600');
res.json(jwks);
});
The rotation procedure
Key rotation requires careful sequencing. The overlap period — where both the old and new key are in the JWKS — is what prevents active sessions from breaking.
- Generate a new RSA key pair and store it in your key management system with status
active. Set the previous key toretiring. - Begin signing all new tokens with the new key. Old tokens signed with the retiring key remain valid — verifiers can still find the retiring key's public key in the JWKS.
- Wait for the overlap period. The minimum safe overlap is the maximum token lifetime — if tokens live for 1 hour, wait at least 1 hour before retiring the old key. For refresh tokens with 30-day lifetimes, you need a 30-day overlap at minimum.
- After the overlap period, set the retiring key to
retiredand remove it from the JWKS. Any remaining tokens signed with the old key will fail verification, but those tokens should all be expired by now.
// Key lifecycle management
async function initiateKeyRotation() {
const newKey = await generateRSA2048Key();
const keyId = `key_${Date.now()}`;
// Store new key
await db.signingKeys.create({
id: keyId,
privateKeyPem: newKey.privateKey,
publicKeyN: newKey.n,
publicKeyE: newKey.e,
status: 'active',
createdAt: new Date(),
retireAfter: new Date(Date.now() + 30 * 24 * 3600 * 1000), // 30 days overlap
});
// Mark all other active keys as retiring
await db.signingKeys.updateMany(
{ status: 'active', id: { not: keyId } },
{ status: 'retiring' }
);
// Schedule retirement of retiring keys after overlap period
await jobQueue.schedule('retire-old-signing-keys', {
runAt: new Date(Date.now() + 30 * 24 * 3600 * 1000),
});
return keyId;
}
// Job: retire old keys after overlap period
async function retireOldSigningKeys() {
await db.signingKeys.updateMany(
{
status: 'retiring',
retireAfter: { lt: new Date() },
},
{ status: 'retired' }
);
}
Client-side JWKS caching with kid lookup
Resource servers that validate tokens should cache the JWKS response rather than fetching it on every request. The caching strategy needs to handle the case where a token arrives with a kid that is not in the cache — this happens when a new key is introduced and the cache has not been refreshed yet.
import { createRemoteJWKSet, jwtVerify } from 'jose';
// jose's createRemoteJWKSet handles caching and kid-based key selection automatically
// It also re-fetches the JWKS when it encounters an unknown kid
const JWKS = createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json'),
{
cacheMaxAge: 3600 * 1000, // Cache for 1 hour
cooldownDuration: 30 * 1000, // Wait 30s before re-fetching on unknown kid
}
);
async function verifyToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
return payload;
}
// Under the hood, jose's JWKS implementation:
// 1. Extracts kid from the token header
// 2. Looks up the key in the cached JWKS
// 3. If not found, re-fetches the JWKS (respecting cooldown)
// 4. If still not found, throws "no applicable key found in JWKS"
Emergency key rotation
If a signing key is compromised, you need to rotate immediately — the normal overlap period no longer applies. Emergency rotation invalidates all active sessions, since you cannot know which sessions used tokens signed with the compromised key. The procedure is the same as above but you set the compromised key to revoked immediately (removing it from the JWKS) rather than retiring it. All tokens signed with the compromised key become unverifiable, which logs out all users. This is the correct response to a key compromise — the cost of a forced re-login is acceptable given the alternative.