The Default Everyone Reaches For, and Why It Falls Apart at Scale
Most teams solve JWT revocation by picking one tool and using it for everything. Either they blacklist every token on every logout, or they go full stateless with 15-minute access tokens and call it a day. Both defaults work fine in a demo. Both fall apart once you have real traffic, real compromised sessions, and a security team asking why a fired employee's token is still valid for another 14 minutes.
The mistake isn't picking blacklist or short-TTL. It's treating revocation as one problem with one solution, instead of a set of different risk levels that each deserve a different mechanism. A user logging out of one browser tab is not the same risk as a leaked signing key. Treating them identically means you either over-engineer the common case or under-protect the dangerous one.
Revocation Has Three Different Blast Radii
Before picking a strategy, figure out what you're actually trying to revoke. There are three distinct scopes, and confusing them is where most implementations go wrong.
Per-session revocation — kill one specific token, issued to one device, without touching anything else. This is "log me out of this laptop."
Per-user revocation — kill every token belonging to one account, across every device. This is "I think my account was compromised, sign me out everywhere."
Per-signing-key revocation — kill every token issued by the system, for every user. This is "the private key leaked, burn it all down."
A denylist handles the first case cleanly but is genuinely awkward for the second — you'd need to enumerate every active token for that user, which means you're now storing enough state to basically reconstruct a session table anyway. Rotating the signing key handles the third case instantly but nukes every legitimate session too, which is why it's a break-glass action, not a routine one.
Ranked Decision Criteria
When you're deciding what combination of mechanisms to run in production, weigh these in order:
- How fast does revocation need to take effect? Immediate (compromised account) vs. acceptable-within-minutes (normal logout).
- How many tokens does a typical revocation event affect? One token, one user's tokens, or all tokens.
- What's the read cost per request? Every mechanism that isn't "do nothing until expiry" adds a lookup somewhere.
- What's your actual token lifetime? Short-lived access tokens shrink the exposure window for every other mechanism you layer on top.
That fourth point is the one people underweight. A short TTL isn't a competing strategy to blacklisting — it's a multiplier that makes every other strategy cheaper. If your access token lives for 10 minutes, your denylist only ever needs to hold entries for 10 minutes past their creation, because anything older has expired on its own. That's a dramatically smaller, cheaper, self-pruning list compared to blacklisting tokens with a 24-hour lifetime.
Mechanism 1: Short TTL + Refresh Token Rotation
This is the backbone, not the whole solution. Issue access tokens with a short lifetime — commonly somewhere in the 5 to 15 minute range — and pair them with a longer-lived refresh token that's rotated on every use.
The rotation part matters more than the short TTL alone. Each time a refresh token is exchanged for a new access token, you issue a brand new refresh token and invalidate the old one. If an old, already-rotated refresh token ever gets presented again, that's a strong signal of token theft — someone has a copy of a token that should no longer exist. This is exactly the mechanism covered in refresh token reuse detection without storing all tokens in database, and it lets you detect compromise without keeping a growing table of every token ever issued.
The cost here is mostly UX and infrastructure for the refresh flow itself, not per-request latency, since access token validation stays stateless.
Mechanism 2: Selective Blacklist for the High-Risk Minority
Don't blacklist every logout. Reserve the denylist for the events that actually justify a lookup on every request: explicit sign-out-everywhere, admin-forced revocation, or suspected compromise.
Store only the "jti" claim plus an expiry matching the token's own expiry, in Redis, with a TTL set to the token's remaining lifetime. This keeps the list self-cleaning — entries disappear the moment the token would have expired anyway, so you never accumulate dead weight. Combine this with a short access token TTL and the list stays small even under heavy sign-out volume, because entries live for minutes, not days.
The real cost is the extra round trip. A Redis lookup on every authenticated request adds latency, and at high request volumes that lookup has to be fast and horizontally scalable, which usually means a dedicated Redis cluster rather than the same instance backing your session cache. If you're already running distributed caching for other purposes, the patterns in redis caching strategy for paginated and filtered api results and how to implement distributed rate limiting across multiple api servers with redis apply here too — same class of problem: keep the hot path fast, keep the cache invalidation consistent across nodes, and watch for stale reads right after a revocation event in multi-region setups where Redis replication lag means one region still accepts a token another region just killed.
Mechanism 3: Signing Key Rotation for Mass Compromise
This is the nuclear option and it should stay that way. Rotating the signing key invalidates every token system-wide instantly, with zero per-request lookup cost, because the old signature simply stops validating.
Use it only when you have real indication the key itself was exposed, not as a routine sign-out mechanism. It logs out every single user simultaneously, which is an acceptable cost during an actual incident and an unacceptable cost during a Tuesday afternoon logout click.
Three Worked Scenarios
Scenario A: User clicks "log out" on their phone. No blacklist needed. Just discard the refresh token client-side and let the short-lived access token expire naturally within minutes. This is the case covered in how to invalidate JWT without database lookup on every request — you accept a small exposure window in exchange for zero infrastructure cost.
Scenario B: Support ticket says "I think someone else is in my account." This needs immediate, per-user revocation. Add the user's active token identifiers to the denylist right now, and increment a token-version claim on the user record so all future refresh attempts using old refresh tokens fail too. This is the territory explored in how to revoke a jwt before it expires and how to invalidate a JWT before it expires without a database — for this specific case, the denylist earns its latency cost because the alternative is an active attacker with a live session.
Scenario C: A signing key was accidentally committed to a public repo. Rotate the key immediately. Every token dies at once. Yes, everyone gets logged out. That's the correct trade at that point — better than leaving a forged-token risk live while you debate scope.
Default Recommendation
Run short-lived access tokens with refresh token rotation as your baseline for every user, all the time. Layer a Redis-backed denylist only for explicit sign-out-everywhere and suspected-compromise events, with TTLs matched to token expiry so the list self-prunes. Keep signing key rotation as an incident-response action, documented and rehearsed, not a feature exposed anywhere in your normal auth flow.
The exception worth naming: if your access tokens already live for 24 hours or more because some legacy client can't handle frequent refresh calls, you don't have a revocation problem, you have a token lifetime problem. Fix that first. No amount of clever blacklisting compensates for a token that's valid long after anyone should trust it.