How Do You Actually Log Someone Out Of A JWT


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

An access token stays valid through logout until exp A fifteen minute access token is minted at login. The user logs out partway through, which deletes the cookie in the browser, and the token itself keeps being accepted by every service until its exp timestamp is reached. valid still valid, still accepted everywhere one access token, 15 minute exp login token minted, exp stamped logout cookie deleted, tab closed exp token stops being accepted
Deleting the cookie removes your copy. Any copy someone else took keeps working until exp, which is why revocation has to live somewhere the server controls.

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

Revoking a jti compared with revoking a sid One login produces one sid and about thirty two jti values over an eight hour day. Revoking the jti you are holding blocks a single token, and the next refresh mints another one under the same session. Revoking the sid rejects the refresh and kills every token under that login. sid a3f9c1e0, one login, 8 hours jti 1 jti 2 jti 3 jti 4 jti 5 jti 32 revoke jti 4, the one you hold refresh mints jti 5, session alive revoke sid a3f9c1e0 refresh rejected, all 32 dead
Same logout, two ids. The jti you happen to be holding is one string out of about thirty two, and the session mints the next one fifteen minutes later without asking anybody.

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

Denylist steady state and per-pod memory Logouts arrive at ninety per second and each entry lives for a nine hundred second token lifetime, so the live key count settles at eighty one thousand. Storing a million entries as strings costs under a hundred megabytes and a million objects for the garbage collector, while a HashSet of longs costs about sixteen megabytes and no objects. the set drains as fast as it fills logouts in 90 per second live keys 90 × 900 = 81,000 TTL expiry out 90 per second an older entry does nothing, exp already rejects that token a million entries, held in the pod strings, under 100MB a million objects for every full GC to walk HashSet<long>, about 16MB no object per entry, nothing to trace
Arithmetic, not a measurement. Because the size follows from the rate and the TTL, a logout spike costs you a bigger set for exactly one token lifetime and then it drains itself.

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

Publishing a revocation to every gateway pod, and reconciling The logout handler writes the revocation key to Redis and publishes it. Two pods add it to their local set within milliseconds, while a pod that was mid startup never receives the message. Every couple of seconds each pod SCANs the keyspace and swaps in the full set, which is how the pod that missed the message catches up. logout handler Redis SET rev:sid:a3f9c1e0 EX 640 PUBLISH pod A, set updated in ms pod B, mid startup, missed it pod C, set updated in ms every couple of seconds, with jitter SCAN rev:sid:* swap the whole set in pod B catches up a cold pod subscribes first, then loads, then takes traffic
Pub/sub has no acknowledgement and no replay, so a pod that was starting, or that blipped for two seconds, only finds out on its next poll.

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

The three revocation keys and what each one stops A per-session key holds a placeholder and stops one login on one device. A per-user key holds a cutoff timestamp and stops every session that user has. A global key holds a cutoff timestamp and stops everyone. Each of the three is a single entry. key value what it stops rev:sid:a3f9c1e0 1 one login, on one device rev:usr:8812 cutoff ts every session that user has rev:global cutoff ts everyone, every session one entry each, whatever the blast radius
The bottom row is the breach case. Ten million sessions and it's still one entry, because the timestamp applies to all of them without listing any of them.

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.

Want to get blog posts over email?

Enter your email address and get notified when there's a new post!

Comments