If JWTs Are Stateless, How Do You "Logout" A User?
One sentence shown in the listing card.
This is the question interviewers ask when they want to separate candidates who have "used" JWTs from people who have "shipped" them. The honest answer to this question is uncomfortable: "we can't logout a user, not really - not the way stateful sessions let us".
JWT logout is always a compromise, and the engineers who pretend otherwise are the ones who end up with revoked-but-still-valid tokens floating around for many hours.
This post explains how it actually works in production, so grab a cup of coffee, and let's dive into it.
Why This Question is a Trap
Let's first start with stateful sessions. A stateful session is just a row in a database. When a user performs logout, we just delete row corresponding to that session from the database:
DELETE FROM sessions WHERE id = ?DELETE FROM sessions WHERE id = ?When the next request comes for the same session id comes, the lookup fails. Done. On the other hand, JWT is a signed claim the server handed to the client. It looks like this:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3MTIzNDU2Nzh9.s1gNaTUr3_bvM...
header payload (user_id, expiry) signatureeyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3MTIzNDU2Nzh9.s1gNaTUr3_bvM...
header payload (user_id, expiry) signatureVerification never touches database. The server only checks for signature as well as for expiry and that's it. That's the whole pitch - no session store, no lookup, it scales horizontally, and works across services. Now we must ask ourselves: if we don't look the token anywhere, how do we invalidate it? Clearly, we can't invalidate something we don't track, from the logout perspective - statelessness is a bug.
Naive Answers and Why They Fail
Before getting to the correct and nuanced answers, let's first look at few naive answers so that we don't accidentally give them in the interview.
- "Just delete the token on the client". We are trusting the client to forget. An attacker who has the token doesn't give a damn what our frontend does. This is like hiding the tab and not the actual logout.
- "Set a short expiry". Better, but "short" is doing a lot of work here. If our access token lives for 15 minutes, a stolen token is still valid for upto 15 minutes after the user hits "logout". To be honest, this is too long for most apps.
- "Rotate the signing key". Do we really want to do that? Just think what is means: all the users on the planet are now logged out including those who didn't ask for.
Once we work with enough JWT systems, the realization dawns upon us: to do logout properly, we have to "give up" a little statelessness. The question is how little.
Real Recipe: Short Access Token + Refresh Tokens
This is what >90% of real production systems actually implement. It is the default in Auth0, Okta, Cognito, Clerk, Supabase - all of them. In this approach, there are two tokens instead of one:
- Access Tokens: These are short lived JWT (5-15 minutes), sent wit every API call and are validated statelessly.
- Refresh Tokens: These are long-lived opaque tokens (days to weeks) and are sent only to the auth endpoint to mint new access tokens. These are stored server-side.
With this approach, the logout means deleting the refresh token row in the database. The access token still validates until it expires, but when the client tries to refresh, the server says no. Within our access-token TTL, the user is fully logged out.
1# logout endpoint
2def logout(refresh_token, user_id):
3 db.execute(
4 "DELETE FROM refresh_tokens WHERE token_hash = %s AND user_id = %s",
5 sha256(refresh_token), user_id,
6 )
7 # access token still valid until expiry, that's the tradeoff1# logout endpoint
2def logout(refresh_token, user_id):
3 db.execute(
4 "DELETE FROM refresh_tokens WHERE token_hash = %s AND user_id = %s",
5 sha256(refresh_token), user_id,
6 )
7 # access token still valid until expiry, that's the tradeoffWhy hash the refresh token stored in the DB? The raw token lives in the client's cookie. The DB only sees sha256(token). If the database leaks, attackers can't use the hashes to impersonate anyone.
Here, the tradeoff is clear: our worst case exposure window after logout is the access token TTL. We must pick it case by case based on what it costs.
| Access token TTL | Worst-case exposure | Good for |
|---|---|---|
| 5 minutes | 5 min | Banking, healthcare, admin |
| 15 minutes | 15 min | Most SaaS |
| 1 hour | 1 hour | Low-risk consumer apps |
| 24 hours | 24 hours | Don't |
When 5 Minutes is Still Too Long: Denylist
Some apps cannot tolerate any post-logout window. Few examples are: passwords change, accounts compromise, sign out all devices after an admin reset. For these, we need immediate revocation, which means checking a server-side denylist on every request. We have given up the stateless property, but only for the revocation check:
1def validate_access_token(token):
2 claims = jwt.decode(token, SECRET, algorithms=['HS256'])
3
4 if redis.exists(f"revoked:{claims['jti']}"): # denylist check
5 raise InvalidToken("revoked")
6
7 return claims1def validate_access_token(token):
2 claims = jwt.decode(token, SECRET, algorithms=['HS256'])
3
4 if redis.exists(f"revoked:{claims['jti']}"): # denylist check
5 raise InvalidToken("revoked")
6
7 return claimsJTI (JWT ID) is a standard claim holding a unique id per token. We must use CSPRNG not just a plain counter. On logout, we insert the jti into Redis with a TTL equal to the token's remaining lifetime:
def revoke(token):
claims = jwt.decode(token, SECRET, algorithms=['HS256'])
ttl = claims['exp'] - int(time.time())
redis.setex(f"revoked:{claims['jti']}", ttl, "1")def revoke(token):
claims = jwt.decode(token, SECRET, algorithms=['HS256'])
ttl = claims['exp'] - int(time.time())
redis.setex(f"revoked:{claims['jti']}", ttl, "1")Here, the TTL is an important part. We never have to cleanup the denylist because entries expire when the token they revoke would have expired anyway. The lists stays small by construction.
The cost: one Redis EXISTS call per authenticated request. At ~0.5ms p99, it is cheap but not free and not stateless. We must budget accordingly.
The "Logout Everywhere" Problem: Token Versioning
When user clicks "sign out of all devices", or changes their password, or a compromise is detected, we need to revoke every token ever issues to this user - across every device, every tab, every stale session. Enumerating and blocklisting every outstanding token works but gets expensive. An elegant fix is a single-integer version number on the user record:
ALTER TABLE users ADD COLUMN token_version INT NOT NULL DEFAULT 0;ALTER TABLE users ADD COLUMN token_version INT NOT NULL DEFAULT 0;Every JWT includes this version in its claims:
1def issue_access_token(user):
2 return jwt.encode({
3 'sub': user.id,
4 'tv': user.token_version, # ◄── here
5 'exp': int(time.time()) + 900,
6 'jti': secrets.token_urlsafe(16),
7 }, SECRET, algorithm='HS256')1def issue_access_token(user):
2 return jwt.encode({
3 'sub': user.id,
4 'tv': user.token_version, # ◄── here
5 'exp': int(time.time()) + 900,
6 'jti': secrets.token_urlsafe(16),
7 }, SECRET, algorithm='HS256')Validation checks that the claim matches the current database value:
1def validate_access_token(token):
2 claims = jwt.decode(token, SECRET, algorithms=['HS256'])
3 user = cache.get_or_fetch(claims['sub'])
4
5 if claims['tv'] != user.token_version:
6 raise InvalidToken("superseded")
7
8 return claims1def validate_access_token(token):
2 claims = jwt.decode(token, SECRET, algorithms=['HS256'])
3 user = cache.get_or_fetch(claims['sub'])
4
5 if claims['tv'] != user.token_version:
6 raise InvalidToken("superseded")
7
8 return claimsNow, logging out from every device is one SQL statement:
UPDATE users SET token_version = token_version + 1 WHERE id = $1;UPDATE users SET token_version = token_version + 1 WHERE id = $1;Every previously issued token now fails validation on its next request. But there's a cost to it: a user lookup per request. We must cache it aggressively. As user's token versions change rarely, so a 30s TTL is safe and absorbs ~99% of reads. A per user Redis key with pub-sub driven invalidation gets us to microsecond lookups.
Refresh Token Rotation (No One Talks About This)
The refresh token is our most valuable credential because it mints access tokens for weeks. If an attacker steals it, they get persistent access even when the user logs out. We must rotate refresh token on every use. Each refresh returns a new refresh token and invalidates the old one.
1def refresh(old_refresh_token):
2 row = db.fetch_one(
3 "SELECT * FROM refresh_tokens WHERE token_hash = %s",
4 sha256(old_refresh_token),
5 )
6
7 if not row:
8 raise InvalidToken("unknown")
9 if row.used_at is not None:
10 # --- reuse detection: someone used a token twice ---
11 revoke_entire_family(row.family_id)
12 raise InvalidToken("reuse detected — all sessions revoked")
13
14 new_refresh = secrets.token_urlsafe(32)
15 db.transaction([
16 "UPDATE refresh_tokens SET used_at = now() WHERE id = %s",
17 "INSERT INTO refresh_tokens (token_hash, family_id, user_id, ...) VALUES (...)",
18 ])
19
20 return {
21 "access_token": issue_access_token(row.user_id),
22 "refresh_token": new_refresh,
23 }1def refresh(old_refresh_token):
2 row = db.fetch_one(
3 "SELECT * FROM refresh_tokens WHERE token_hash = %s",
4 sha256(old_refresh_token),
5 )
6
7 if not row:
8 raise InvalidToken("unknown")
9 if row.used_at is not None:
10 # --- reuse detection: someone used a token twice ---
11 revoke_entire_family(row.family_id)
12 raise InvalidToken("reuse detected — all sessions revoked")
13
14 new_refresh = secrets.token_urlsafe(32)
15 db.transaction([
16 "UPDATE refresh_tokens SET used_at = now() WHERE id = %s",
17 "INSERT INTO refresh_tokens (token_hash, family_id, user_id, ...) VALUES (...)",
18 ])
19
20 return {
21 "access_token": issue_access_token(row.user_id),
22 "refresh_token": new_refresh,
23 }The subtle part is reuse detection. Imagine an attacker steals our refresh token and uses it before we do. The attacker gets a new refresh token; ours is now marked used. When we try to refresh, the server sees a used token being used again: that's the signal.
The server doesn't know which party is legitimate, so it revokes the entire family (every descendant refresh token for this login) and forces re-authentication.
This is how "OAuth 2.0's refresh token rotation with automatic reuse detection" works.
The Hybrid We Actually Want In Production
We should combine everything above where each piece addresses a different failure mode:
- Day-to-day logout: Delete the refresh token. Access token dies within 15 minutes. This supports 99% of our traffic.
- Password change: We bump
token_versionfield. Due to this, every access token across every device dies on the next request. - Account compromise / admin action: We add to the denylist for immediate effect and also bump token_version.
Each mechanism has a different cost and a different guarantee. When combined, they cover the case stateful sessions give us for free.
When to Just Use Server Side Sessions
Sometimes the answer is "don't use JWTs". JWTs earn their keep in specific scenarios:
- Cross authentication in a microservice mesh, where a shared session is a bottleneck.
- Third-party API authorization (OAuth), where the token has to be self-contained.
- Stateless edge validation at CDN workers, where we don't have database access.
If we are building a monolithic web app where all requests hit the same backend, session cookies backed by Redis will serve us better. Logout it trivial and revocation is immediate. We avoid the entire complexity tree above. The JWT ecosystem has taught many engineers to reach for JWTs by default. This should not be the case. We should only use them when we need their specific properties.
Principles
- Access tokens are claims. Refresh tokens are credentials. We should treat them differently: short-lived vs. long-lived, stateless vs. stateful, many vs. few.
- Logout invalidates the refresh token, not the access token. The access token just has to expire faster than we care about.
- Every revocation mechanism has a cost and latency. We should pick the one that matches our worst-case tolerance.
- Rotate refresh tokens and detect reuse because it is the difference between a short-lived compromise and a hidden year-long backdoor.
- If the logout has to be instant and global, we are no longer stateless. That's fine - we just have to be honest about it and pick Redis over hand wringing.
JWTs are not bad; they are specific tools with specific tradeoffs. Logout is where those tradeoffs come due. We must know what we are trading before any incident teaches us.
What's your logout stack? Reply with your gnarliest revocation bug you have shipped - I want to hear the war stories.
-- Anirudh