The JWT specification defines a self-contained token: the signature is verified against the public key, the expiry is checked, and if both pass, the token is valid. There is no database lookup, no network call. This statelessness is the core performance benefit of JWTs. It is also the reason token revocation is an afterthought in most implementations — the mechanism that makes tokens fast also makes them difficult to invalidate before expiry.
Why revocation matters
Consider the scenarios where you need to invalidate a token before it expires:
- A user clicks "Log out all devices" — their access tokens on other devices should stop working immediately.
- An employee is terminated — their tokens should not work until they expire, potentially hours later.
- A user reports account compromise — you need to immediately revoke all active credentials.
- A token is discovered in a public repository or log file — you need to revoke that specific token.
- An OAuth client is deauthorized — all tokens issued to that client should become invalid.
In all these cases, waiting for token expiry is the wrong answer. If your access tokens have a 1-hour TTL and an employee is terminated, they have up to an hour of continued access after termination. For most organizations, that is not acceptable.
Strategy 1: short access token TTL
The simplest approach to limiting the revocation window: issue access tokens with very short TTLs — 5 to 15 minutes. When a token needs to be revoked, you only need to wait for its TTL to expire. Refresh tokens are revoked in the database, so the user cannot obtain a new access token after the old one expires. The effective revocation window is your access token TTL.
The drawback: short TTL tokens create refresh overhead. If a user is making API calls continuously, they need a new token every 5 minutes. This adds latency and requires client-side token management. For human-facing applications with OAuth flows, this is manageable. For high-frequency machine-to-machine calls, 5-minute tokens can be impractical.
Strategy 2: token revocation list (blocklist)
Maintain a set of revoked token JTIs (JWT IDs) in a fast store like Redis. On every token validation, check whether the JTI is in the revocation list. If it is, reject the token even if the signature and expiry are valid.
// JWT with JTI for revocation tracking
function issueAccessToken(userId: string, clientId: string): string {
const jti = crypto.randomUUID();
return jwt.sign({
sub: userId,
jti,
client_id: clientId,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 900 // 15 minutes
}, PRIVATE_KEY, { algorithm: 'RS256' });
}
// Revocation
async function revokeAccessToken(jti: string, expiresAt: number): Promise<void> {
// Only need to store until token would have expired naturally
const ttl = Math.max(0, expiresAt - Math.floor(Date.now() / 1000));
await redis.setex(`revoked:${jti}`, ttl, '1');
}
// Validation with revocation check
async function validateAccessToken(token: string): Promise<JwtPayload> {
const payload = jwt.verify(token, PUBLIC_KEY, {
algorithms: ['RS256']
}) as JwtPayload;
const isRevoked = await redis.exists(`revoked:${payload.jti}`);
if (isRevoked) throw new TokenRevokedError();
return payload;
}
The downside is that every token validation now requires a Redis lookup, eliminating the stateless performance benefit of JWTs. You have effectively turned JWTs into opaque tokens with extra steps. At high validation rates, this Redis call can become a bottleneck. Mitigate it with a small in-process cache with a TTL of 1–2 minutes — most requests will hit the cache, and the worst-case revocation delay is the cache TTL.
Strategy 3: opaque access tokens
Use opaque token references for access tokens — a random string that maps to a record in your database. Every validation requires a database lookup. This is the traditional session-based model. Revocation is trivially O(1): delete or mark the record. The cost is the database lookup on every API call, which requires a fast data store (Redis or a database with a hot read path).
// Opaque token issuance
async function issueOpaqueToken(
userId: string,
scopes: string[]
): Promise<string> {
const tokenValue = 'at_' + base64url(crypto.randomBytes(32));
const tokenHash = crypto.createHash('sha256').update(tokenValue).digest('hex');
await redis.setex(
`access_token:${tokenHash}`,
900, // 15-minute TTL
JSON.stringify({ user_id: userId, scopes, issued_at: Date.now() })
);
return tokenValue;
}
// Opaque token validation — fast Redis lookup
async function validateOpaqueToken(tokenValue: string): Promise<TokenPayload> {
const tokenHash = crypto.createHash('sha256').update(tokenValue).digest('hex');
const data = await redis.get(`access_token:${tokenHash}`);
if (!data) throw new InvalidTokenError();
return JSON.parse(data);
}
// Revocation — instant and O(1)
async function revokeOpaqueToken(tokenValue: string): Promise<void> {
const tokenHash = crypto.createHash('sha256').update(tokenValue).digest('hex');
await redis.del(`access_token:${tokenHash}`);
}
Revocation propagation in distributed systems
When you have multiple API services doing distributed JWT validation, revocation propagation is the hardest problem. Service A's in-process cache might hold a token for 60 seconds after revocation. Service B, C, and D have their own caches. A revoked token might continue working at some services for up to the cache TTL after revocation.
Approaches to tighten this window:
- Publish revocation events to a message bus (Kafka, Redis pub/sub). Each service subscribes and invalidates its local cache immediately on receiving a revocation event.
- Use a shared distributed cache (Redis cluster) as the revocation list rather than per-process caches. The consistency guarantee is as strong as your Redis cluster's replication.
- Accept the propagation delay and make it explicit in your SLAs: revocation takes effect within 60 seconds across all services.
// Revocation with pub/sub propagation
async function revokeTokenAndBroadcast(jti: string, expiresAt: number): Promise<void> {
const ttl = Math.max(60, expiresAt - Math.floor(Date.now() / 1000));
// Write to the revocation store
await redis.setex(`revoked:${jti}`, ttl, '1');
// Broadcast to all service instances to clear their local caches
await redis.publish('token:revoked', JSON.stringify({ jti, timestamp: Date.now() }));
}
// Each service subscribes to revocation events
const subscriber = redis.duplicate();
subscriber.subscribe('token:revoked', (message) => {
const { jti } = JSON.parse(message);
localTokenCache.delete(jti);
});