Token revocation is one of the fundamental tensions in auth system design. You want fast, stateless verification (which JWTs provide) but you also want immediate revocation when a user logs out, a session is compromised, or an account is suspended. These two requirements pull in opposite directions. Understanding the trade-offs clearly — rather than picking one approach and pretending the problem is solved — is the foundation of a revocation strategy that actually works.
Reference tokens: always fresh, always a round trip
A reference token is an opaque random string that acts as a pointer to token data stored server-side. Each API request that presents a reference token requires a lookup in your token store (database or Redis) to retrieve the associated claims and verify validity. Revocation is instant: delete the entry from the store and the token is immediately invalid on the next request.
// Reference token implementation
import crypto from 'crypto';
// Issue reference token
async function issueReferenceToken(userId, orgId, scopes) {
const token = crypto.randomBytes(32).toString('base64url');
const tokenId = `rt_${token}`;
await redis.setex(`token:${tokenId}`, 3600, JSON.stringify({
userId,
orgId,
scopes,
issuedAt: Date.now(),
}));
return tokenId;
}
// Verify reference token (called on every API request)
async function verifyReferenceToken(tokenId) {
const data = await redis.get(`token:${tokenId}`);
if (!data) return null; // not found = expired or revoked
// Optionally reset TTL on use (sliding expiry)
await redis.expire(`token:${tokenId}`, 3600);
return JSON.parse(data);
}
// Revoke reference token (instant)
async function revokeReferenceToken(tokenId) {
await redis.del(`token:${tokenId}`);
// The token is now invalid — no waiting for expiry
}
The cost is latency: every API request makes at least one Redis call (typically 0.5–2ms within a data center). At high request rates this adds up, and Redis becomes a critical dependency — if your token store is unavailable, no API requests can be authorized.
Self-contained JWTs: fast, stale until expiry
A JWT contains all necessary claims and is cryptographically self-verifying. No storage lookup required. Verification takes 0.1–0.3ms using cached JWKS. The fundamental limitation: once issued, a JWT is valid until its exp claim. You cannot revoke it directly — the token is a signed fact, and changing the signature would invalidate it.
// JWT has no revocation mechanism built in
// The best you can do is wait for expiry or maintain a blocklist
// On user logout: you can invalidate the refresh token
// but the access JWT remains valid for up to its lifetime
async function logout(userId, sessionId, accessTokenJti) {
// Invalidate refresh token (user cannot get new access tokens)
await db.query(
'UPDATE sessions SET revoked_at = NOW() WHERE id = $1',
[sessionId]
);
// The access JWT is still valid for up to 15 more minutes
// Option 1: accept this (the trade-off for stateless JWT)
// Option 2: add jti to a blocklist
await redis.zadd(
'jwt_blocklist',
Date.now() + 900000, // score = expiry timestamp (for cleanup)
accessTokenJti
);
}
Hybrid approach: short-lived JWTs + refresh token revocation
The production standard: issue JWTs with short lifetimes (5–15 minutes), verified locally with no round trip. Maintain revocability through the refresh token layer — revoking the refresh token means the user cannot get new access tokens after the current one expires.
// Token issuance: short-lived JWT + long-lived refresh token
async function issueTokenPair(userId, orgId, sessionId) {
// Access token: short-lived JWT, verified locally
const accessToken = await signJWT({
sub: userId,
org: orgId,
sid: sessionId,
jti: crypto.randomUUID(),
}, { expiresIn: '15m' });
// Refresh token: opaque, stored in DB for revocability
const refreshToken = crypto.randomBytes(32).toString('base64url');
const refreshHash = createHash('sha256').update(refreshToken).digest('hex');
await db.query(`
INSERT INTO refresh_tokens (session_id, token_hash, user_id, org_id, expires_at)
VALUES ($1, $2, $3, $4, NOW() + INTERVAL '30 days')
`, [sessionId, refreshHash, userId, orgId]);
return { accessToken, refreshToken };
}
// Revoke entire session (e.g., admin suspends account)
async function revokeSession(sessionId) {
await db.query(
'UPDATE sessions SET revoked_at = NOW() WHERE id = $1',
[sessionId]
);
await db.query(
'UPDATE refresh_tokens SET revoked_at = NOW() WHERE session_id = $1',
[sessionId]
);
// Access JWT for this session is still valid up to 15 minutes
// Acceptable for most use cases; use jti blocklist for immediate effect
}
// Refresh flow: checks session validity before issuing new access token
async function refreshAccessToken(rawRefreshToken) {
const hash = createHash('sha256').update(rawRefreshToken).digest('hex');
const rt = await db.query(`
SELECT rt.*, s.revoked_at as session_revoked_at
FROM refresh_tokens rt
JOIN sessions s ON s.id = rt.session_id
WHERE rt.token_hash = $1
AND rt.revoked_at IS NULL
AND rt.expires_at > NOW()
AND s.revoked_at IS NULL -- session-level revocation check
`, [hash]);
if (!rt.rows[0]) throw new Error('Invalid or revoked refresh token');
return issueTokenPair(rt.rows[0].user_id, rt.rows[0].org_id, rt.rows[0].session_id);
}
JTI blocklist for immediate revocation
When you need truly immediate revocation — account compromise, fraud detection, legal hold — a JTI blocklist combined with short-lived JWTs provides the best balance. The blocklist is a small set (only explicitly revoked tokens, not all issued tokens) that fits in Redis memory and can be checked in a single O(1) ZSCORE operation.
// JTI blocklist: only add when immediate revocation is needed
// For normal logout, rely on refresh token revocation
async function revokeTokenImmediately(jti, expiresAt) {
// Score = expiry Unix timestamp (for automatic cleanup)
await redis.zadd('jwt_blocklist', expiresAt, jti);
}
// In JWT verification middleware
async function verifyToken(token) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.yourapp.com',
algorithms: ['ES256'],
});
// Only check blocklist if the token has a jti
if (payload.jti) {
const score = await redis.zscore('jwt_blocklist', payload.jti);
if (score !== null) throw new Error('Token has been revoked');
}
return payload;
}
// Periodic cleanup: remove expired entries from blocklist
// Run daily via cron — keeps the set from growing unbounded
async function pruneBlocklist() {
const now = Math.floor(Date.now() / 1000);
const removed = await redis.zremrangebyscore('jwt_blocklist', '-inf', now);
console.log(`Pruned ${removed} expired entries from JWT blocklist`);
}
Choose your approach based on your security requirements: pure reference tokens for regulated industries requiring instant revocation, hybrid JWT + refresh revocation for most SaaS applications, and add a JTI blocklist selectively for high-value revocation events. There is no single right answer — only trade-offs that must match your threat model.