JWT secret rotation is a critical security practice, but doing it without service disruption requires careful planning. This guide shows you how to rotate JWT secrets with zero downtime using the kid (Key ID) header claim and multiple key support.
Why Rotate JWT Secrets?
Regular key rotation limits the impact of potential compromises:
- Limit exposure window - If a key is compromised, only tokens signed during that period are affected
- Compliance requirements - Many regulations mandate periodic key rotation
- Security hygiene - Proactive rotation is better than reactive rotation after a breach
Without proper planning, rotation requires all users to re-authenticate. Let's avoid that.
The Problem with Naive Rotation
A simple rotation approach fails because:
- You deploy a new secret to your auth service
- Existing tokens signed with the old secret become invalid
- All users get 401 errors and must re-login
// NAIVE: This breaks existing tokens
// Before rotation
const token = jwt.sign(payload, oldSecret);
// After rotation - this throws Invalid signature!
const decoded = jwt.verify(token, newSecret);The Solution: Multiple Keys with kid
The kid (Key ID) header claim identifies which key was used to sign a token. By supporting multiple keys simultaneously, you can:
- Issue new tokens with a new key (new kid)
- Verify tokens with either old or new key
- After transition period, stop accepting the old key
Implementation
Step 1: Key Store Structure
// keys.js
const crypto = require('crypto');
class KeyStore {
constructor() {
// Store multiple keys with metadata
this.keys = new Map();
}
addKey(kid, secret, isActive = true) {
this.keys.set(kid, {
kid,
secret,
isActive,
createdAt: Date.now()
});
}
getActiveKey() {
// Return the current signing key
for (const [kid, key] of this.keys) {
if (key.isActive) return key;
}
throw new Error('No active key');
}
getKey(kid) {
return this.keys.get(kid);
}
getAllVerificationKeys() {
// Return all keys for verification
return Array.from(this.keys.values());
}
}
module.exports = KeyStore;Step 2: Token Signing with kid
// auth.js
const jwt = require('jsonwebtoken');
function signToken(payload, keyStore) {
const activeKey = keyStore.getActiveKey();
return jwt.sign(payload, activeKey.secret, {
algorithm: 'HS256',
keyid: activeKey.kid, // Include kid in header
expiresIn: '1h'
});
}
// Example usage
const keyStore = new KeyStore();
keyStore.addKey('key-2026-01', process.env.JWT_SECRET_V1, true);
const token = signToken({sub: 'user-123', role: 'admin'}, keyStore);
// Header: {"alg": "HS256", "typ": "JWT", "kid": "key-2026-01"}Step 3: Token Verification with Multiple Keys
// middleware.js
const jwt = require('jsonwebtoken');
function verifyToken(token, keyStore) {
// Decode header without verifying
const decoded = jwt.decode(token, {complete: true});
if (!decoded || !decoded.header.kid) {
throw new Error('Missing kid in token header');
}
const key = keyStore.getKey(decoded.header.kid);
if (!key) {
throw new Error('Unknown key ID');
}
// Verify with the specific key
return jwt.verify(token, key.secret, {
algorithms: ['HS256']
});
}
// Express middleware
function authMiddleware(keyStore) {
return (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({error: 'No token provided'});
}
try {
req.user = verifyToken(token, keyStore);
next();
} catch (err) {
res.status(401).json({error: 'Invalid token'});
}
};
}Step 4: Rotation Process
// rotation.js
const crypto = require('crypto');
async function rotateKey(keyStore) {
// 1. Generate new key
const newKid = `key-${new Date().toISOString().slice(0, 7)}`;
const newSecret = crypto.randomBytes(32).toString('hex');
// 2. Add new key as active
const oldActiveKey = keyStore.getActiveKey();
keyStore.addKey(newKid, newSecret, true);
// 3. Mark old key as inactive (but still valid for verification)
keyStore.keys.get(oldActiveKey.kid).isActive = false;
// 4. Store new secret in environment/KMS
process.env.JWT_SECRET_NEW = newSecret;
console.log(`Rotated to new key: ${newKid}`);
console.log(`Old key still valid for verification: ${oldActiveKey.kid}`);
return { newKid, newSecret };
}
// Schedule old key removal after transition period
async function removeOldKey(keyStore, kid, gracePeriodDays = 7) {
setTimeout(() => {
keyStore.keys.delete(kid);
console.log(`Removed old key: ${kid}`);
}, gracePeriodDays * 24 * 60 * 60 * 1000);
}Rotation Timeline
Here's a typical rotation schedule:
| Day | Action |
|---|---|
| Day 0 | Generate new key, set as active, old key stays for verification |
| Day 1-7 | New tokens use new key, old tokens verified with old key |
| Day 7 | Remove old key from verification (tokens older than 7 days should be expired anyway) |
| Day 90 | Schedule next rotation |
Token Expiration and Rotation
Your token expiration time should be shorter than your rotation grace period:
- Token expiration: 1 hour to 24 hours
- Rotation grace period: 7 days
- Rotation frequency: Every 90 days
With 1-hour tokens and 7-day grace period, by day 7 all old tokens will have naturally expired.
RS256 Rotation (Easier!)
With RS256, rotation is even easier because you only need to rotate the private key. The public key for verification can be distributed via JWKS endpoint.
// JWKS endpoint for RS256
const express = require('express');
const app = express();
app.get('/.well-known/jwks.json', (req, res) => {
const jwks = {
keys: keyStore.getAllPublicKeys().map(key => ({
kid: key.kid,
kty: 'RSA',
alg: 'RS256',
use: 'sig',
n: key.publicKeyN,
e: 'AQAB'
}))
};
res.json(jwks);
});Best Practices
- Automate rotation - Don't rely on manual processes
- Monitor key usage - Track which keys are being used
- Have a rollback plan - Be able to revert if issues arise
- Test rotation - Practice in staging before production
- Document the process - Your team should know how to rotate keys
Tools
- JWT Secret Generator - Generate new keys for rotation
- JWT Validator - Verify tokens with specific keys
- RSA Key Generator - Generate RS256 key pairs