JWT Token Revocation: Strategies and Implementation
Learn JWT token revocation strategies including blacklisting, whitelisting, and token versioning with practical implementation examples.
In This Article
Why JWT Revocation Is Hard
JWT's greatest strength — being stateless — is also its greatest weakness when it comes to revocation. Unlike session-based authentication where you simply delete the session from the server, a JWT is valid from the moment it's signed until its exp claim passes. There's no built-in mechanism to say "this token is no longer valid."
This creates real problems. When a user logs out, changes their password, or gets banned, their existing JWTs remain valid until they expire. In this guide, we'll explore practical strategies for revoking JWTs, their trade-offs, and implementation code. For more on why this matters, see our JWT security best practices.
When You Need Token Revocation
Common scenarios that require revoking a JWT before it expires:
- User logout: The user's token should be invalidated immediately
- Password change: All existing tokens should be revoked
- Account suspension: A banned user's tokens must stop working
- Security breach: All tokens for a compromised account must be revoked
- Permission change: When a user's role changes, old tokens with stale permissions should be invalidated
Strategy 1: Short-Lived Tokens (No Revocation)
The simplest approach: don't revoke at all. Instead, make access tokens so short-lived (5–15 minutes) that the damage window is minimal.
How It Works
- Access tokens expire in 5–15 minutes
- Refresh tokens are stored server-side and can be revoked
- On logout or security event, revoke the refresh token
- The access token becomes useless within minutes
Pros and Cons
- Pros: No revocation infrastructure needed, truly stateless
- Cons: Up to 15-minute delay before access is cut off, not suitable for immediate revocation needs
This is the default approach in many OAuth 2.0 implementations and is combined with refresh token strategies for session management.
Strategy 2: Token Blacklist with Redis
Maintain a server-side list of revoked token IDs (jti claims). On each request, check if the token's jti is in the blacklist.
Implementation
const redis = require('redis');
const jwt = require('jsonwebtoken');
const client = redis.createClient({ url: process.env.REDIS_URL });
async function blacklistToken(token) {
const decoded = jwt.decode(token);
if (!decoded?.jti) throw new Error('Token missing jti claim');
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl <= 0) return; // already expired, no need to blacklist
await client.set(`blacklist:${decoded.jti}`, '1', { EX: ttl });
}
async function isTokenBlacklisted(token) {
const decoded = jwt.decode(token);
if (!decoded?.jti) return false;
const exists = await client.exists(`blacklist:${decoded.jti}`);
return exists === 1;
}
// Middleware
async function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'No token' });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
if (await isTokenBlacklisted(token)) {
return res.status(401).json({ error: 'Token has been revoked' });
}
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
}
// Logout endpoint
app.post('/auth/logout', authMiddleware, async (req, res) => {
await blacklistToken(req.headers.authorization.split(' ')[1]);
res.json({ message: 'Logged out successfully' });
});
Pros and Cons
- Pros: Immediate revocation, TTL matches token lifetime (auto-cleanup), simple implementation
- Cons: Adds a Redis lookup to every request (introduces state), requires Redis infrastructure, slightly reduces performance
Strategy 3: Token Whitelist (Session Store)
Instead of tracking revoked tokens, track valid tokens. Only tokens in the whitelist are accepted.
Implementation
async function whitelistToken(userId, tokenId, token) {
const decoded = jwt.decode(token);
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
await client.set(`session:${userId}:${tokenId}`, '1', { EX: ttl });
}
async function isTokenWhitelisted(userId, tokenId) {
const exists = await client.exists(`session:${userId}:${tokenId}`);
return exists === 1;
}
// Revoke all tokens for a user (e.g., on password change)
async function revokeAllUserTokens(userId) {
const keys = await client.keys(`session:${userId}:*`);
if (keys.length > 0) await client.del(keys);
}
Pros and Cons
- Pros: Easy to revoke all tokens for a user, full control over active sessions
- Cons: Most stateful approach, essentially recreates server sessions, Redis lookup on every request
Strategy 4: Token Versioning
Add a version number to user records and to tokens. If the token version is older than the current user version, reject it.
Implementation
// When issuing a token, include the user's current token version
function issueToken(user) {
return jwt.sign(
{
sub: user.id,
role: user.role,
tokenVersion: user.tokenVersion // from database
},
process.env.JWT_SECRET,
{ expiresIn: '1h', jwtid: crypto.randomUUID() }
);
}
// Middleware checks token version against database
async function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'No token' });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
const user = await db.users.findById(decoded.sub);
if (!user || decoded.tokenVersion !== user.tokenVersion) {
return res.status(401).json({ error: 'Token revoked' });
}
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
}
// Revoke all tokens: increment version
async function revokeAllTokens(userId) {
await db.users.update(userId, {
tokenVersion: { $inc: 1 }
});
}
Pros and Cons
- Pros: Revoke all tokens for a user with a single database update, no blacklist needed, minimal storage
- Cons: Requires a database lookup on every request (can be cached), version must be checked against a store
Using the jti Claim
The jti (JWT ID) claim provides a unique identifier for each token. It's essential for blacklist and whitelist strategies. Always include a unique jti (UUID) when issuing tokens:
{
"sub": "user123",
"jti": "550e8400-e29b-41d4-a716-446655440000",
"iat": 1720627200,
"exp": 1720630800
}
The jti claim prevents replay attacks and enables precise tracking of individual tokens. See the OWASP cheat sheet for more on using jti for token management.
Comparing All Strategies
| Strategy | Revocation Speed | Infrastructure | Complexity | State |
|----------|----------------|----------------|------------|-------|
| Short-lived only | Delayed (5-15 min) | None | Low | Stateless |
| Blacklist (Redis) | Immediate | Redis | Medium | Semi-stateful |
| Whitelist (Redis) | Immediate | Redis | Medium | Stateful |
| Token Versioning | Immediate | Database | Low | Semi-stateful |
For most applications, a combination of short-lived access tokens + refresh token revocation + token versioning provides the best balance of security, performance, and simplicity.
Conclusion
JWT revocation requires trade-offs between statelessness and control. For most applications, short-lived access tokens combined with server-side refresh token management and token versioning provide immediate revocation without significant performance impact. Use our JWT decoder to verify that your tokens include the jti and tokenVersion claims needed for revocation strategies. Also check our guide on handling token expiration for a complete picture of the token lifecycle.
Need to decode a JWT token? Try our free JWT decoder tool — no sign-up required, runs entirely in your browser.