TLDR: you can’t. At least not the token itself.
JWTs are signed and carry claims. One of those is exp, the point after which the token should stop being accepted. That’s the only thing the token says about its own lifetime, and nothing you do afterward changes it. Log out of the app, close the tab, delete the cookie: the token is still valid right up to exp. If someone copied it, it still works.
The problem
What logging out does to the token
So how do you actually log out, or kill a token when you need to?
It comes down to one thing: put some state back on the server, and figure out how to check it without increasing your latency.
One assumption before we go further: access tokens are short, 15 minutes, and the long-lived thing is an opaque refresh token you store and rotate. That choice is doing most of the work here, and everything below falls apart without it.
The first idea everyone has is Redis. Keep a set of revoked tokens, check it on every request. That’s the right instinct and the wrong implementation, and the reason why is a counting problem.
What actually goes in Redis
Before we go further, let’s clear this part. What do we store in Redis? Do we store the token itself?
You never store the token. It’s a live credential, and anyone who can read your cache walks away with working bearer tokens for the next fifteen minutes. It’s also ~800 bytes each. Your token carries two ids. jti is this specific token, minted fresh on every refresh. sid is the session, generated once at login and carried through every refresh for as long as the user stays logged in. One login, one sid, many jti.
Say someone works an 8-hour day on 15-minute tokens. That’s about 32 tokens under one sid. At logout you only hold the current one. Blacklist that jti and their next refresh hands them a brand new token with a brand new jti that isn’t on any list. You blocked a string. The session is still alive.
What you revoke decides what survives
So you revoke the sid.
SET rev:sid:a3f9c1e0 1 EX 640
Key is the session id. The value is a placeholder as existence of the key is the answer. TTL is whatever’s left of the token’s life, so Redis deletes the entry at the exact moment it stops mattering.
How big does that set actually get
Here’s what people get wrong. Your denylist doesn’t hold every logout that ever happened. It only holds logouts from the last token lifetime. Older ones already fail on exp, so those entries are doing nothing and Redis has dropped them. Entries drain out as fast as they come in. So the size isn’t your logout count. It’s peak logout rate × access token TTL. If logouts arrive at 90/sec and each entry survives 900 seconds, the steady-state count is 90 × 900 = 81,000.
At 40 bytes for a session id, 81,000 entries is about 3MB. Even a million entries is 40MB raw, and under 100MB once you count .NET’s object overhead. That still fits as gateway pods usually run with a 512MB to 1GB limit, but it’s heavier than it needs to be, and a million live strings sitting in gen2 get walked on every full GC.
The fix is to stop storing strings. Hash the sid to 64 bits and keep a HashSet<long>. No object per entry, nothing for the collector to trace, and a million entries drops to ~16MB. A hash collision would reject one valid session, not admit a revoked one, so the failure direction is the safe one.
Steady state, and what it costs the pod
One more assumption: all of this happens at the gateway. It’s the only entry point, so it’s the only place that needs the set. That’s a handful of pods holding 10MB each, not every service in your fleet. The tradeoff is that everything behind the gateway now trusts whatever identity it forwards, so that internal boundary has to be real. Otherwise anything that can reach a service directly skips auth entirely.
Getting the set into every pod
So how does the set get into every pod?
Redis is still the source of truth but instead of it being the source of truth for a request, each gateway pod will just keep a copy. There are two mechanisms to orchestrate it with.
Push. Logout writes the key to Redis and publishes on a channel. Every pod is subscribed and adds the sid to its local set within a few milliseconds. This is the fast path and it’s what makes revocation feel instant.
Reconcile. Every second or two, each pod pulls the full current set from Redis and swaps it in.
Redis pub/sub is fire-and-forget. There’s no acknowledgement, no replay, no delivery guarantee. A pod that was mid-startup when the message went out never sees it. A pod that had a two-second network blip never sees it. The poll is what heals that.
Recommended: schedule the polls with some ms of jitter. Otherwise all fifty pods hit Redis at the same instant, every two seconds, forever.
One more thing which is easy to miss. A pod that starts with an empty set accepts everything until its first reconcile lands. During a rolling deploy that’s a real window. So a cold pod does a full load before it takes traffic: subscribe to the channel first, then pull the set, otherwise a revocation landing between the two gets missed by both. Use SCAN, not KEYS. Redis is single-threaded and KEYS walks the whole keyspace in one uninterruptible go, while SCAN returns a cursor and a small batch at a time, so nothing stalls.
Push for speed, poll for what push missed
What actually happens on a request
Logout: the gateway already parsed the token, so it has the sid and exp. It writes rev:sid:{sid} with a TTL of the remaining token life, publishes the sid on the channel, and deletes the refresh token row so the session can’t be extended. Three operations, once per logout.
Order matters here. Verify signature and exp first, because those reject forged and expired tokens for free.
if (_revoked.Contains(Hash(sid))) return Unauthorized();
if (_userCutoff.TryGetValue(uid, out var uc) && uc > iat) return Unauthorized();
if (_globalCutoff > iat) return Unauthorized();
What if you need to kill everything at once
A breach, a leaked signing key, or just someone hitting “log out all devices” on their account page. Per-session revocation will break here. Millions of active sessions can’t each have an entry, it won’t scale. We don’t have to make millions of decisions though, just one.
SET rev:global <now, unix seconds> EX 900
One key holding a timestamp. Any token whose iat is older than that gets rejected. Ten million sessions gone, one entry, one message on the channel.
Same shape but one level down. rev:usr:{uid} holds a timestamp too, and that’s our “log this user out everywhere”. One entry regardless of number of devices. And rev:sid:{sid} is the per-session case we already have. So there are 3 keys with three scopes: one session, one user’s sessions, everyone.
Three keys, three scopes
What to watch when this scales
Skew between pods. Pod A reconciled at t=0, pod B at t=1.5s. A logout at t=1.0s means that for one second the same token is accepted by one pod and rejected by another, depending on which one the load balancer picked. Users see a flaky logout and it’s hard to reproduce. We can’t remove this but only bound it. Pick your reconcile interval knowing that’s the number you’re choosing.
Redis going down. Serve the last known snapshot, or 401 everyone. Failing open and allowing everyone is usually the right call. The worst case is that a logout from the last few minutes hasn’t reached every pod yet. The alternative is nobody can log in at all, which is destructive. If you fail open, don’t just monitor Redis. Monitor how stale each pod’s copy is. Redis can be perfectly healthy while a pod sits on data from four minutes ago.
For payments, admin, anything you can’t undo: skip the local set and hit Redis directly. That’s a network call, but it’s on a small fraction of your traffic, and those are the routes where you’d rather 401 someone than let a revoked session through.
Someone bumping the TTL. The whole design rests on rate × TTL being small. Move access tokens to 24 hours and 2M logouts/day becomes 2M live entries. Whoever owns that config should know it’s load-bearing, because it won’t look like an auth change when it happens.
Clock drift. You compute the TTL as exp - now. If the pod handling logout runs 30 seconds fast, the entry evicts while the token is still being accepted everywhere else, and the token comes back to life. Pad the TTL by a minute. An extra minute of storage costs nothing. Evicting early means the token starts working again.
Where do we stand after this
None of this makes a JWT revocable. The token is still a signed statement that stays true until it expires. What you’re doing is keeping a small, short-lived list of sessions you’ve decided to stop trusting, and making sure every pod can check it without asking anyone.
That’s it. The rest is picking a TTL you can live with.
Comments