JSON Web Tokens (JWTs) have become the de facto standard for securing APIs and managing user authentication in modern web applications. While JWTs offer the allure of a stateless architecture—where the server does not need to store session data—this convenience introduces significant security challenges. Specifically, if a JWT is stolen, it remains valid until it expires. This post explores robust strategies for JWT rotation and revocation, contrasting how these mechanisms function in both stateful and stateless environments.
The Core Problem: Token Theft and Expiration
In a basic JWT implementation, once a token is issued, the server trusts its signature until the exp (expiration) claim is reached. If an attacker intercepts this token, they have unauthorized access until that window closes. To mitigate this, we must implement token rotation (issuing new tokens) and revocation (invalidating tokens early).
Strategy 1: The Stateless Approach with Short-Lived Access Tokens
In a purely stateless architecture, the server cannot check a central database to see if a token has been revoked. Therefore, the primary defense is reducing the window of opportunity for attackers. We achieve this by using short-lived Access Tokens paired with long-lived Refresh Tokens.
How it works:
- The client receives a short-lived Access Token (e.g., 15 minutes).
- When the Access Token expires, the client uses the Refresh Token to get a new Access Token.
- The Refresh Token is stored securely (e.g., HttpOnly cookie) and has a longer lifespan (e.g., 7 days).
Even in a stateless setup, we can implement minimal revocation by checking a "token blacklist" or "last active" timestamp stored in a fast key-value store like Redis. While this introduces a slight state dependency, it is minimal compared to traditional session management.
// Pseudo-code for verifying a JWT with a quick Redis check
async function verifyToken(token, redisClient) {
const decoded = jwt.verify(token, SECRET_KEY);
// Check if the token ID exists in a short-term blacklist or has been updated
const tokenRecord = await redisClient.get(`token:${decoded.jti}`);
if (tokenRecord === 'revoked') {
throw new Error('Token revoked');
}
// Optional: Check if the user's "last password change" is newer than the token issuance
const user = await getUser(decoded.sub);
if (decoded.iat < user.lastPasswordChange) {
throw new Error('Token issued before password change');
}
return decoded;
}
Strategy 2: The Stateful Approach with Server-Side Sessions
In a stateful architecture, the server maintains a record of active sessions. This provides granular control over token validity but requires more infrastructure. Here, JWTs can still be used as the carrier of claims, but the server validates them against a database of active sessions.
Revocation Mechanics:
- Logout: When a user logs out, the server deletes the session record from the database. Any future requests with that JWT will fail validation because the server cannot find the associated session.
- Force Logout: The server can delete all sessions for a specific user ID, immediately invalidating all active tokens across all devices.
Implementation: Token Blacklisting in Node.js
Whether stateful or stateless (with slight state), implementing a blacklist is crucial for immediate revocation. Below is an example of how to blacklist a JWT using Node.js and Redis. We store the jti (JWT ID) claim as the key and set an expiration time equal to the token's remaining validity.
const jwt = require('jsonwebtoken');
const redis = require('redis');
const redisClient = redis.createClient();
// Blacklist a token upon logout
async function blacklistToken(req, res) {
const token = req.headers.authorization.split(' ')[1];
const decoded = jwt.decode(token); // Decode without verification to get jti
if (!decoded || !decoded.jti) {
return res.status(400).send('Invalid token structure');
}
// Calculate TTL based on when the token expires
const now = Math.floor(Date.now() / 1000);
const ttl = decoded.exp - now;
// Store the jti in Redis with an expiration time
await redisClient.setex(`blacklist:${decoded.jti}`, ttl, 'revoked');
res.status(200).send('Successfully logged out');
}
// Middleware to check blacklist during request validation
async function checkBlacklist(req, res, next) {
const token = req.headers.authorization.split(' ')[1];
const decoded = jwt.decode(token);
if (decoded && decoded.jti) {
const isBlacklisted = await redisClient.get(`blacklist:${decoded.jti}`);
if (isBlacklisted) {
return res.status(401).send('Token has been revoked');
}
}
next();
}
Best Practices for Secure Rotation
- Use Unique IDs: Always include a unique
jticlaim in your JWTs. This allows for precise identification of tokens for revocation purposes. - Rotate Refresh Tokens: Whenever an access token is refreshed, issue a new refresh token and invalidate the old one. This prevents replay attacks if a refresh token is stolen after use.
- Secure Storage: Never store JWTs in
localStorageif your application is vulnerable to XSS. UseHttpOnlyandSecurecookies instead to mitigate cross-site scripting risks. - Monitor for Anomalies: Implement logging and monitoring to detect unusual patterns of token usage, such as multiple simultaneous logins from different geographic locations.
Conclusion
Implementing secure JWT rotation and revocation requires balancing security with performance. Stateless architectures rely heavily on short token lifespans and efficient caching layers (like Redis) to handle revocation without storing full sessions. Stateful architectures offer easier revocation at the cost of increased server memory and database load. For most modern applications, a hybrid approach—using short-lived access tokens, rotating refresh tokens, and employing a fast key-value store for token blacklisting—provides the best balance of security, scalability, and user experience.