JWT Revocation: Blocklist Patterns for Stateless Tokens
JWTs are stateless by design — that's the whole appeal, no database round-trip on every request. It's also exactly why "log this user out right now" or "this token was just compromised, kill it" doesn't have a built-in answer. A JWT with 14 minutes left on its exp is valid until it expires, full stop, no matter what happens on your server in the meantime. This guide covers the patterns that actually solve that: a Redis-backed blocklist, scoped correctly so you don't throw away the performance JWTs were supposed to buy you.
Why "Just Use Short Expiry" Isn't a Full Answer
Short-lived access tokens (5–15 minutes) paired with refresh tokens, as covered in JWT token expiration best practices, shrink the exposure window — but they don't close it. A leaked token, a user clicking "log out everywhere," an admin disabling a compromised account: all of these need the token to stop working *now*, not in 14 minutes. If your product has any of those requirements, you need an explicit revocation mechanism, and expiry tuning alone won't get you there.
The Pattern: a Denylist, Not an Allowlist
Don't try to track every valid token — that reintroduces the stateful lookup JWTs were meant to avoid, on every single request. Instead, track only the tokens you've explicitly revoked, and check against that (much smaller) set:
1. On logout / revocation, store the token's jti (JWT ID claim) in Redis, with a TTL equal to the token's *remaining* time-to-live — not a fixed TTL. There's no reason to keep a revoked entry around after the token would have expired naturally anyway.
2. On every request, after verifying the signature as normal, check whether the token's jti exists in the blocklist. If it does, reject the request even though the signature is perfectly valid.
3. Redis is the natural fit here: the check is a single EXISTS lookup, sub-millisecond, and the TTL means Redis cleans up after itself — you never accumulate revoked entries forever.
// On logout / revocation
const decoded = jwt.decode(token); // no verification needed here, just reading claims
const remainingSeconds = decoded.exp - Math.floor(Date.now() / 1000);
if (remainingSeconds > 0) {
await redis.set(`revoked:${decoded.jti}`, '1', 'EX', remainingSeconds);
}// On every authenticated request, after normal signature verification
async function isRevoked(decoded) {
const result = await redis.get(`revoked:${decoded.jti}`);
return result !== null;
}This requires your tokens to actually have a jti claim. If you're not currently including one, start now — without it you have nothing stable to key the blocklist on besides the full token string (which works, but wastes memory storing a long string per entry instead of a short UUID).
Revoking Every Token for a User at Once
The per-token pattern above handles "kill this one session." For "log this user out everywhere" (password change, account compromise, admin action), track a per-user invalidation timestamp instead of individual tokens:
// On "log out everywhere" / password change
await redis.set(`user-invalidated-before:${userId}`, Math.floor(Date.now() / 1000));// On every request
const invalidatedBefore = await redis.get(`user-invalidated-before:${decoded.sub}`);
if (invalidatedBefore && decoded.iat < Number(invalidatedBefore)) {
// token was issued before the invalidation point — reject
}This is a single Redis key per user instead of one entry per token, and it correctly catches every token issued before the cutoff — including ones you never explicitly blocklisted — because it checks iat (issued-at) against a threshold rather than tracking individual token IDs.
Where This Fits with RS256/JWKS Setups
If multiple services verify tokens independently against a shared JWKS endpoint, the revocation check still needs to happen somewhere — either each verifying service hits the same Redis instance, or you centralize verification behind an API gateway that owns the revocation check so individual services don't each need Redis access. Pick one pattern consistently; a revocation check that only some services perform is a security hole with a reassuring name.
What This Costs You
Be honest with yourself about the tradeoff: a blocklist check is a network round-trip per request, which is exactly the cost JWTs exist to avoid. For most applications this is a non-issue — a sub-millisecond Redis lookup is negligible next to your actual request latency — but if you're operating at a scale where that round-trip genuinely matters, that's a sign you may want asymmetric signing with short-lived tokens and no revocation check at all, accepting the small expiry window as the tradeoff instead.
Frequently Asked Questions
Do I need a `jti` claim for this to work?
You need *something* stable to key the blocklist entry on. A jti (a UUID generated at issuance) is the standard choice — see the JTI claim glossary entry. Without one, you'd have to key on the full token string, which works but is wasteful.
Why not just maintain an allowlist of valid tokens instead?
An allowlist means checking Redis for every single request regardless of whether anything was ever revoked, and storing an entry for every active session rather than just the exceptions. A denylist only grows when something is actually revoked, which for most apps is a small fraction of total traffic — much cheaper in both storage and lookup volume.
Does this work the same way for RS256 as HS256?
Yes — revocation is independent of the signing algorithm. The blocklist check happens after normal signature verification, regardless of whether that verification used a shared secret or a public key from a JWKS document.
What if Redis goes down?
Decide this explicitly rather than letting it happen by accident: failing open (allow requests through when the blocklist is unreachable) keeps your app available but briefly re-admits anything that was revoked during the outage; failing closed (reject everything) is safer but turns a Redis outage into a full authentication outage. Most teams fail open for short Redis blips and alert loudly, since the exposure window is bounded by token expiry anyway.
Isn't this just reinventing sessions?
Sort of, deliberately — you're adding back a small, targeted piece of statefulness (only for the tokens you've revoked) while keeping the stateless verification path for everything else. That's a reasonable middle ground, not a contradiction: pure stateless JWTs and session-backed auth aren't the only two options.
---
Generate a secure JWT secret for your revocation-aware auth setup: our free JWT Secret Generator runs entirely in your browser.
*Related: JWT Token Expiration Best Practices · Access Token vs Refresh Token · JWKS in Production*