backend / authentication / jwt / 05_revocation_logout.md

JWT Revocation and Logout

7 interview angles 7 min read source

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:

  1. Short access tokens (5-15 min) — bounds the worst case.
  2. Token version bump — increment user’s version on logout-everywhere.
  3. Blocklist for the specific tokens in flight.
  4. 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’s jti or bump user’s token version.
  • “How do you log a user out of all devices?” — bump a token_version claim 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.