Why You Can't Just "Revoke" a JWT
Here's the uncomfortable truth about JWTs: revocation was never part of the design. The whole point of a JSON web token is that it's self-contained. The server signs it, hands it to the client, and never has to think about it again until it comes back for verification. No database lookup, no session store, no round trip. That's exactly why JWTs got popular for distributed systems where multiple backend services need to trust a token without hitting a shared store every time.
But that statelessness is also the problem. If a user logs out, gets fired, or has their account compromised, the JWT you issued them is still cryptographically valid until its exp claim says otherwise. The server has no built-in way to say "actually, ignore this one." Every article that tells you to "just implement a revocation strategy" is skipping the part that actually matters — how you build that strategy without reintroducing all the state and lookup overhead JWTs were supposed to eliminate.
There are three real approaches that engineering teams actually use in production, and each one makes a different tradeoff between security, latency, and complexity. Let's go through them properly.
Option 1: Short-Lived Access Tokens + Refresh Token Rotation
This is the approach most modern auth systems default to, and it doesn't try to revoke the JWT at all. Instead, it limits the blast radius of a stolen or unwanted token by making it expire fast — think 5 to 15 minutes — and pairing it with a longer-lived refresh token that's used to mint new access tokens.
The refresh token itself is typically opaque, not a JWT, and it's stored server-side (in a database or Redis) so it can be revoked instantly. When a client's access token expires, it sends the refresh token to a /token/refresh endpoint, the server checks it against the store, and issues a new short-lived JWT.
Rotation is the part people skip. Every time a refresh token is used, the server should invalidate it and issue a brand new one. If the same refresh token is ever presented twice, that's a strong signal it's been stolen — you kill the entire token family immediately.
POST /token/refresh
{ "refresh_token": "rt_8f3a..." }
// Server logic:
// 1. Look up refresh_token in store
// 2. If not found or already used -> reject, revoke entire session chain
// 3. If valid -> mark as used, issue new access JWT + new refresh token
Tradeoffs: the access token itself is never truly revoked mid-flight — you're just accepting that a compromised token is only dangerous for a few minutes. That's often good enough, but not if you need to kill a session right now for a compliance or fraud reason. You also now have a stateful refresh token store to manage, which partially defeats the "no database lookup" appeal of JWTs, though only on the less frequent refresh path rather than every single request.
A common mistake here is setting the access token lifetime too long "for convenience," like an hour or more. That erases most of the security benefit. Keep it short and lean on rotation to handle the UX side.
Option 2: Server-Side Denylist (Redis Blacklisting)
This approach accepts the tradeoff head-on: yes, it reintroduces a lookup on every request, but it gives you actual, immediate revocation. Every JWT gets a unique identifier in its payload — the jti claim — and when you want to kill a token before it expires, you write that jti into a fast key-value store, typically Redis.
{
"jti": "3f29a1c2-88b1-4e3a-9f21-2d1a7c9e4b02",
"sub": "carlos",
"exp": 1648037164
}
On logout, or an admin-triggered kill, you run something like:
SET revoked:3f29a1c2-88b1-4e3a-9f21-2d1a7c9e4b02 1 EX 900
The EX value should match the token's remaining time-to-live — no point storing a revoked entry longer than the token would have been valid anyway. Every authenticated request now does an EXISTS revoked:<jti> check before trusting the JWT's claims. If it's present, reject with a 401 regardless of what the signature says.
This is the only one of the three approaches that gives you true, immediate, per-token revocation. It's also the one people get wrong most often in two specific ways: forgetting to set a TTL on the Redis key (so your denylist grows forever), and putting the Redis check behind a slow or unreplicated cache that becomes a single point of failure for your entire auth layer. If Redis is down, do you fail open (security risk) or fail closed (availability risk)? Decide that explicitly, don't let it happen by accident.
If you're already running Redis for other purposes, it's worth thinking about key naming and expiry patterns consistently across your stack — the same principles that show up when structuring Redis keys for cached API results apply just as much to a revocation denylist as they do to cached query results.
Tradeoffs: you get real revocation, but you've now made every request dependent on a shared, low-latency data store. In a multi-region setup, that store needs to be either globally replicated or accepted as a source of eventual consistency lag — meaning a revoked token might still work for a second or two in a distant region right after revocation.
Option 3: Versioned User Tokens
This is the lightest-weight option, and it trades granularity for simplicity. Instead of tracking individual tokens, you store a single tokenVersion (or sessionVersion) integer on the user's record in your database. Every issued JWT embeds that version number as a claim.
{
"sub": "carlos",
"tokenVersion": 4,
"exp": 1648037164
}
On every request, you compare the JWT's tokenVersion against the current value stored for that user. If they don't match, the token is dead — no matter what its exp says. To revoke everything for a user, you increment their tokenVersion by one. Every JWT issued before that point instantly stops being valid.
This is dramatically cheaper to operate than a Redis denylist because you're usually already loading the user record on authenticated requests, or you can cache it with a normal TTL-based cache since it changes rarely. There's no separate revocation store to maintain.
The catch is granularity: this revokes all sessions for a user at once. You can't kill one specific device's session while leaving others alive — unless you extend the model with a per-device version map, which starts looking a lot like the denylist approach again, just keyed differently.
Tradeoffs: cheap, simple, no extra infrastructure — but blunt. It's excellent for "user changed their password" or "admin suspended this account" scenarios. It's the wrong tool if you need to kill one leaked token out of five active sessions without logging the user out everywhere else.
Comparing the Three Approaches
| Approach | Revocation speed | Extra infra | Granularity | Best fit | |---|---|---|---|---| | Refresh rotation | Delayed (until access token expires, minutes) | Refresh token store | Per session | General-purpose auth, good UX | | Redis denylist | Immediate | Redis (or similar) | Per token | Compliance, fraud response, "kill now" | | Versioned tokens | Immediate | None (uses existing DB) | Per user (all sessions) | Password resets, account suspension |
Combining Them in Practice
Most production systems don't pick just one. A typical setup uses short-lived access tokens with refresh rotation as the baseline, a tokenVersion claim for the common "log out everywhere" and "password changed" cases, and a Redis denylist reserved specifically for high-priority, immediate kill scenarios like a confirmed account compromise. That way the expensive, stateful check only runs when you actually need instant revocation, not on every single request by default.
When you're deciding, ask how fast revocation actually needs to be for your threat model. If "within 15 minutes" is acceptable, rotation alone might be enough and you avoid the Redis dependency entirely. If you're handling anything regulated or security-sensitive, that delay usually isn't acceptable, and the denylist earns its keep despite the added lookup.
One more thing worth checking regardless of which approach you pick: make sure your JWT verification logic actually validates the algorithm and signature correctly before any of this revocation logic even runs. A denylist doesn't help you if an attacker can forge a token with alg: none or swap in their own key — that's a separate class of vulnerability entirely, but it's worth auditing at the same time you're building out revocation.