JWT Revocation and Logout
The hard part of JWT auth. Stateless JWTs are great for scaling — but “the user clicked logout” or “this account was compromised” requires kicking out a token before its exp. Strict statelessness can’t do this; pragmatic solutions trade off some of the statelessness.
The fundamental tension
Stateless JWT means servers don’t store per-token state — they verify by signature and claims. Revocation means storing “this token is invalid” somewhere — which IS state.
Five strategies, increasing in revocation power and decreasing in statelessness:
| Strategy | Revocation latency | Statelessness |
|---|---|---|
| Short access tokens, no revocation | minutes (wait for expiry) | full |
| Refresh token revocation | up to access token lifetime | mostly |
| Access token blocklist (Redis) | immediate | gone |
| Token versioning per user | immediate | mostly |
| Server-side sessions | immediate | none |
Most production systems combine: short access tokens (5-15 min) + refresh token revocation + optional access blocklist for the highest-stakes operations.
Strategy 1: Short access tokens, accept the lag
Access token: 15-minute lifetime
Logout: revoke the refresh token; do nothing to access tokens.
The user’s existing access token works for up to 15 minutes after logout. After that, it expires, and they can’t refresh (revoked).
Pros: pure stateless. No server-side checks on access tokens. Cons: 15-minute window where logout isn’t enforced.
Acceptable for most consumer apps. Unacceptable for high-risk operations (banking, admin).
Strategy 2: Refresh token revocation
class RefreshToken(models.Model):
token = models.CharField(unique=True)
user = models.ForeignKey(User)
revoked = models.BooleanField(default=False)
expires_at = models.DateTimeField()
family_id = models.UUIDField()
On logout: mark the refresh token (or family) revoked. The /token endpoint refuses to issue new access tokens. Existing access tokens drain in their normal short window.
Pair with refresh token rotation (see 04_access_refresh_tokens.md) for detection of token reuse.
This is the dominant pattern. Combines almost-stateless servers (only the refresh endpoint touches the DB) with reasonable security.
Strategy 3: Access token blocklist
import redis
r = redis.Redis()
def revoke_access_token(jti: str, exp_seconds: int):
r.setex(f"revoked:{jti}", exp_seconds, "1")
def is_revoked(jti: str) -> bool:
return r.exists(f"revoked:{jti}") > 0
# Verification middleware
def verify_jwt(token):
payload = jwt.decode(token, key, algorithms=["RS256"])
if is_revoked(payload["jti"]):
raise PermissionDenied("Token revoked")
return payload
Every API request: signature check + Redis lookup for jti. Slow path (Redis call) only for blocklisted tokens.
Pros: immediate revocation. Cons: every API call hits Redis. With 10k RPS, that’s 10k Redis ops/sec just for blocklist checks.
Mitigation: cache “I checked recently, not revoked” in memory for a few seconds. Trade-off: revocation latency becomes that cache TTL.
Use when:
- Critical “log out NOW” requirements (admin tools, banking).
- Compromise response (force-logout a specific user).
Strategy 4: Token versioning per user
class User(models.Model):
# ...
token_version = models.IntegerField(default=0)
def login(user):
payload = {"sub": user.id, "tv": user.token_version, "exp": ...}
return jwt.encode(payload, key, algorithm="RS256")
def verify(token):
payload = jwt.decode(token, key, algorithms=["RS256"])
user = User.objects.get(id=payload["sub"])
if payload["tv"] != user.token_version:
raise PermissionDenied("Token outdated")
return payload, user
Each JWT includes a “token version” claim matching the user’s current version. To invalidate all tokens for a user: increment the version. Existing tokens have the old version → rejected.
Pros: simple “kick everyone out” per user. One DB column. Cons: every verify hits the DB. Same Redis-style caching mitigation applies.
Use case: “user changed password, log out everywhere.” Increment token_version.
Strategy 5: Server-side sessions
The opposite of JWT: opaque session ID in a cookie; server-side store (Redis) holds the session.
# Login
session_id = secrets.token_urlsafe(32)
redis.setex(f"sess:{session_id}", 3600, json.dumps({"user_id": user.id, ...}))
response.set_cookie("session", session_id, httponly=True, secure=True)
# Every request
session = redis.get(f"sess:{request.cookies['session']}")
if not session:
raise PermissionDenied
Logout: redis.delete(f"sess:{session_id}"). Done.
Pros: trivially revocable. Mutable session state (roles, etc. update instantly). Cons: server-side state (the Redis lookup on every request).
For most apps, this is simpler than JWT + revocation. JWT shines for cross-service / cross-org tokens and serverless. For typical single-domain web apps, sessions are often the right answer despite the JWT hype.
See ../../07_rest_apis/09_stateful_vs_stateless.md.
Logout flows
Local logout
@app.post("/logout")
def logout(request, response):
rt = request.cookies.get("refresh_token")
if rt:
RefreshToken.objects.filter(token=rt).update(revoked=True)
response.delete_cookie("refresh_token")
return {"message": "Logged out"}
Revoke refresh; clear cookies. Access token drains naturally.
Logout everywhere
@app.post("/logout-all")
def logout_all(user):
# Revoke all refresh tokens for this user
RefreshToken.objects.filter(user=user).update(revoked=True)
# Optionally: increment token version to kill access tokens immediately
user.token_version += 1
user.save()
For “I think my account is compromised — log me out everywhere.”
Logout in OIDC context
OIDC defines end_session_endpoint for ending the IdP session. Plus front-channel and back-channel logout for propagating to all RPs. See ../sso/05_single_logout.md.
Token version vs blocklist — comparison
| Token version | Blocklist | |
|---|---|---|
| Revoke one token | no (kills all of user’s tokens) | yes (by jti) |
| Revoke all of user’s tokens | yes (one increment) | yes (must enumerate all jtis) |
| Storage per token | nothing | one entry per revoked token until exp |
| Verify cost | DB / Redis lookup per request | Redis lookup per request |
| Use case | logout everywhere, password change | revoke specific session |
Many systems combine: token version for blanket revocation; blocklist for per-token surgical revocation.
Refresh token revocation patterns
Database flag
class RefreshToken(models.Model):
token = models.CharField(unique=True)
revoked = models.BooleanField(default=False)
Simple. Lookup on every refresh. Old tokens accumulate; periodically clean up expired ones.
Hash the token
class RefreshToken(models.Model):
token_hash = models.CharField(unique=True)
# Don't store the raw token
# On issue:
raw = secrets.token_urlsafe(32)
RefreshToken.objects.create(token_hash=sha256(raw))
return_to_client(raw)
# On refresh:
raw = request.cookies.get("refresh_token")
record = RefreshToken.objects.get(token_hash=sha256(raw))
If the database leaks, attackers don’t have usable refresh tokens. Same idea as password hashing (though less load-bearing — RT is more like an API key).
Family-based revocation
class RefreshToken(models.Model):
token = models.CharField(unique=True)
family_id = models.UUIDField()
revoked = models.BooleanField(default=False)
def revoke_family(family_id):
RefreshToken.objects.filter(family_id=family_id).update(revoked=True)
On reuse detection or compromise, revoke the whole family. See 04_access_refresh_tokens.md.
The “I want logout everywhere right now” problem
If access tokens are long-lived (>1 hour) and you don’t maintain a blocklist, “log out everywhere” doesn’t actually work — old tokens keep working until they expire.
The fix:
- Short access tokens (5-15 min) — bounds the worst case.
- Token version bump — increment user’s version on logout-everywhere.
- Blocklist for the specific tokens in flight.
- All-of-the-above for high-risk apps.
For consumer apps with 15-minute access tokens, the 15-minute lag is usually acceptable. For banking / admin, immediate revocation is required — accept the cost of per-request DB / Redis lookups.
Common pitfalls
- No revocation strategy — logout doesn’t actually log anyone out. Users assume otherwise.
- Long access tokens (>1 hour) without blocklist — compromised tokens valid for the entire lifetime.
- Stored refresh tokens in plaintext — DB leak = mass account takeover.
- No refresh token rotation — token theft undetected.
- Inconsistent revocation across services — service A respects the blocklist, service B doesn’t, attacker uses service B.
- Token blocklist that grows unboundedly — set TTL = original token’s remaining lifetime; entries auto-expire.
Common interview confusions
- “JWTs can’t be revoked.” — pure stateless JWTs can’t. Practical systems combine JWT with revocation infrastructure (blocklist, token version, refresh revocation).
- “Short tokens solve revocation.” — bound the worst case, but don’t fix immediate-revocation needs (banking, admin).
- “Just check the DB on every request.” — you can, but you’ve reinvented sessions (with extra JWT overhead).
Interview angle
- “How do you revoke a JWT?” — pure JWT: you can’t, beyond expiration. Practical strategies: short access tokens (accept lag); refresh token revocation (kill the refresh, access tokens drain in their short window); access token blocklist (Redis with TTL); user-level token version (claim in JWT, compared to DB on verify).
- “What happens when a user logs out with JWT auth?” — revoke the refresh token (DB / Redis update). Access token still valid until
exp. For immediate logout: blocklist the access token’sjtior bump user’s token version. - “How do you log a user out of all devices?” — bump a
token_versionclaim user-wide; every existing token has the old version and is rejected. Or revoke all refresh tokens for that user. - “How much does maintaining a blocklist cost?” — Redis lookup per API call. With 10k RPS, that’s 10k Redis ops/sec — Redis handles it easily. Cache “recently-checked, valid” entries in process memory to reduce hits.
- “Short tokens vs revocation infrastructure — which?” — depends on stakes. Short tokens (5-15 min) for typical apps; revocation infrastructure for high-stakes ops. Most production systems combine both: short tokens AND a blocklist for immediate emergencies.
- “What if Redis (blocklist) goes down?” — depends on policy. Fail-open: tokens accepted (continued availability, weakened revocation). Fail-closed: reject all (high availability cost). Most systems fail-open with monitoring.
- “Why is JWT revocation harder than session revocation?” — sessions are inherently server-side state; deleting from Redis = revoked. JWTs are stateless by design; revocation requires adding state, defeating part of the JWT advantage.