JWT Token Rotator
Prerequisites & Dependencies
- Node.js 18+ with
npm i jsonwebtokenandnpm i redisfor blacklist storage - Understanding of OAuth 2.0 / OpenID Connect flow
- Secure secret management: environment variables, HashiCorp Vault, or AWS Secrets Manager
Execution Steps
- Issue a short-lived Access Token (e.g., 15min) and a long-lived Refresh Token (e.g., 30 days) upon login
- Store each issued Refresh Token's
jti(JWT ID) in Redis with a TTL matching the token lifetime - On every protected route request, validate the Access Token; if
expired, use the Refresh Token to obtain a new Access Token - During rotation: delete the old Refresh Token
jtifrom Redis (revocation), issue a new Access Token + new Refresh Token pair - Reject refresh requests if the
jtiis already blacklisted or missing (single-use refresh) - Implement a background cleanup job to prune stale blacklist entries and enforce maximum refresh token age
// JWT rotation with Redis blacklist (Node.js/Express)
const jwt = require('jsonwebtoken');
const Redis = require('ioredis');
const redis = new Redis();
const ACCESS_TOKEN_TTL = 15 * 60; // 15 minutes
const REFRESH_TOKEN_TTL = 30 * 24 * 60; // 30 days
// Issue tokens
function issueTokens(userId) {
const access = jwt.sign({ sub: userId, type: 'access' }, process.env.ACCESS_SECRET, { expiresIn: ACCESS_TOKEN_TTL });
const refresh = jwt.sign({ sub: userId, type: 'refresh', jti: crypto.randomUUID() }, process.env.REFRESH_SECRET, { expiresIn: REFRESH_TOKEN_TTL });
// Store refresh jti for revocation
redis.set(refresh.jti, 'revoked', 'EX', REFRESH_TOKEN_TTL);
return { access, refresh };
}
// Refresh endpoint
app.post('/refresh', async (req, res) => {
const { refreshToken } = req.body;
try {
const payload = jwt.verify(refreshToken, process.env.REFRESH_SECRET) as any;
// Check revocation
const isRevoked = await redis.exists(payload.jti);
if (isRevoked) return res.status(401).json({ error: 'Refresh token revoked' });
// Issue new tokens + rotate
const newTokens = issueTokens(payload.sub);
res.json(newTokens);
} catch (err) {
res.status(401).json({ error: 'Invalid refresh token' });
}
});