Backend / Authentication / JWT / 05_revocation_logout.md

JWT Revocation and Logout

Updated 4 interview angles 8 min read source
On this page10
  1. The fundamental tension
  2. Short access tokens, and revoking the refresh token
  3. A jti denylist
  4. A per-user cutoff: log out everywhere
  5. Server-side sessions
  6. Logout flows
  7. Detection and audit
  8. Common pitfalls
  9. Related
  10. Interview angle

JWT Revocation and Logout

The hard part of JWT auth. A stateless JWT is valid until its exp no matter what happens on the server, but “the user clicked logout” or “this account was compromised” has to take effect sooner. Every revocation strategy is a way of putting a little state back, and the design question is how much, where, and what it costs per request.

The fundamental tension

Revocation means storing “this token is invalid” somewhere, which is state. The strategies, from least to most revocation power:

Strategy Revocation takes effect Cost
Short access tokens only after up to one access TTL none
Revoke refresh families after up to one access TTL a lookup per refresh
jti denylist immediately, per token a lookup per request
Per-user cutoff immediately, per user a lookup per request
Server-side sessions immediately a lookup per request: the session

Most production systems combine the first two with one of the middle rows for the cases that cannot wait: logout everywhere, a compromised account, admin and payment routes.

Short access tokens, and revoking the refresh token

Logout revokes the refresh token’s family, so no new access tokens can be minted, and does nothing to the access token, which drains within its 5-15 minutes. Only the refresh endpoint touches the database, which is why this is the dominant pattern (Access and Refresh Tokens). Families, rotation and reuse detection are in Refresh Token Rotation.

The cost is the lag: a stolen access token, or a fired employee’s, keeps working until exp. Acceptable for most consumer apps; not for admin tools, money movement, or an account you know is compromised.

A jti denylist

python
import time

import jwt
from redis.asyncio import Redis

redis = Redis()
LEEWAY = 30            # the same leeway the verifier passes to jwt.decode

async def revoke(claims: dict) -> None:
    ttl = int(claims["exp"] - time.time()) + LEEWAY   # outlive the leeway too
    if ttl > 0:
        await redis.set(f"revoked:{claims['jti']}", 1, ex=ttl)

async def verify(token: str) -> dict:
    claims = jwt.decode(
        token, KEY, algorithms=["RS256"], audience=API, leeway=LEEWAY,
        options={"require": ["exp", "jti", "sub"]},   # a token without jti is a 401
    )
    if await redis.exists(f"revoked:{claims['jti']}"):
        raise jwt.InvalidTokenError("revoked")
    return claims

Three details carry it:

  • TTL = exp - now + leeway. The entry only has to outlive the token, but the verifier accepts a token for leeway seconds past exp. With a TTL of exp - now, the key expires first and the revoked token validates again for up to 30 seconds.
  • require includes jti. Without it, a token with no jti reaches claims["jti"], raises KeyError, and surfaces as a 500 instead of a 401.
  • Every request pays. The lookup runs for every token, not only revoked ones. One Redis node serves 100,000+ simple reads a second at roughly 0.2-0.5 ms per in-zone round trip, so throughput is rarely the problem. Availability is: Redis is now part of every authenticated request.

Small enough to keep in every process

An entry lives at most one access TTL, so the live set is only the revocations made within that window. A million revocations a day at 15-minute tokens is about 10,400 live keys, on the order of a megabyte. That fits in every API process: subscribe to a Redis pub/sub channel for new revocations, replay a Redis Stream after a reconnect, and check a local set with no network hop.

Fail open or fail closed

If the check stays remote, decide what a Redis outage means. Fail open and it becomes a revocation outage: revoked tokens work again. Fail closed and it becomes a full outage. Decide per route — closed for admin and payments, open with an alert for low-risk reads — or remove the dependency with the in-process copy above.

A per-user cutoff: log out everywhere

A denylist kills one token at a time; “log out everywhere” needs every token a user holds, including ones you never recorded. A per-user cutoff does it with one key:

python
ACCESS_TTL = 15 * 60

async def logout_everywhere(user_id: str) -> None:
    cutoff = int(time.time()) + 1                     # the next whole second
    await redis.set(f"cutoff:{user_id}", cutoff, ex=ACCESS_TTL + LEEWAY)
    await revoke_all_refresh_families(user_id)
    await redis.publish(f"logout:{user_id}", "all")   # live connections, below

async def check_cutoff(claims: dict) -> None:
    cutoff = await redis.get(f"cutoff:{claims['sub']}")
    if cutoff is not None and claims["iat"] < int(cutoff):
        raise jwt.InvalidTokenError("issued before logout everywhere")

iat has one-second resolution, so the cutoff is the next whole second, and a login within that same second must issue its token with iat set to the cutoff or it is rejected too. The key expires after one access TTL plus leeway, because by then every older token has expired on its own.

A token version instead

A token_version claim is the same idea with a counter on the user row: mint {"sub": str(user.id), "tv": user.token_version} and reject a mismatch. It costs a claim and a row lookup instead of a key with a TTL. Mint sub as a string either way: PyJWT 2.10 and later reject an integer sub, so the service would refuse its own tokens.

Per-user cutoff jti denylist
Revoke one session no: all of the user’s tokens yes, by jti
Revoke every session yes, one key only the ones you recorded
Storage one key per user, TTL one access TTL one key per revoked token
Typical use logout everywhere, password change revoke one session, a stolen token

Many systems run both: the cutoff for blanket revocation, the denylist for surgical.

Server-side sessions

If every request is going to check a store anyway, an opaque session id in a cookie with the session in Redis is simpler: logout is one DELETE, and roles change instantly. JWTs earn their keep across services and organisations; for a single web app, sessions are often the better answer (JWT vs Session-Based Auth, Stateful and stateless).

Logout flows

Logout on this device

python
@router.post("/auth/logout")
async def logout(response: Response,
                 rt: Annotated[str | None, Cookie(alias=RT_COOKIE)] = None) -> None:
    if rt is not None:
        await revoke_family_of(digest(rt))
    response.delete_cookie(RT_COOKIE, path="/auth/refresh", secure=True,
                           httponly=True, samesite="strict")
    response.headers["Clear-Site-Data"] = '"cookies", "storage"'

Delete the refresh cookie with the attributes it was set with. Starlette’s delete_cookie defaults to path="/" and secure=False, which matches neither a path-scoped cookie nor a __Secure- one, so the cookie survives logout. Clear-Site-Data wipes the origin’s cookies and storage in this browser, and a BroadcastChannel("auth") message tells the other open tabs to drop their in-memory tokens.

Live connections

A revoked token does not close a connection opened with it: a WebSocket is authenticated once, at the handshake, and an SSE stream once, at its request.

Publish a per-user logout:<user_id> event on the broker the nodes already use for fan-out (Scaling WebSocket Applications). Every node then closes that user’s sockets with an application close code and ends their streams (WebSocket and SSE Authentication).

Every service, not one

Revocation enforced in one service and not another is not revocation: the attacker uses the other one. Put the checks in the API gateway or in one shared auth library that every service imports, so a new service cannot ship without them.

Across an identity provider

Logging out of your app does not log the user out of the IdP, and the next login then signs them straight back in. The standard pieces, detailed in Single Logout (SLO):

  • Token revocation (RFC 7009). POST the refresh token to the IdP’s /revoke endpoint so it is dead at the source.
  • RP-initiated logout. Redirect to the IdP’s end_session_endpoint with id_token_hint to end the IdP session.
  • Back-channel logout. The IdP POSTs a logout_token to every app the user signed into. Validate it like any JWT, then check that its events claim contains http://schemas.openid.net/event/backchannel-logout, that it carries sub or sid, that it has no nonce, ideally typ: logout+jwt, and that its jti has not been seen before.
  • Shared Signals (SSF) and CAEP. OpenID’s push channel for “session revoked” and “credentials changed” events, so an IdP can revoke at relying parties before tokens expire.

Detection and audit

Count denylist and cutoff hits: a revoked token still being presented is either a client that missed the logout or someone holding a copy, and a spike for one user is worth an alert. Audit-log every revocation with who did it, why, and its scope (one session, all sessions, admin action).

Common pitfalls

  • No revocation at all. Logout clears the browser and nothing else; the tokens keep working.
  • Long access tokens without a denylist or cutoff. A compromised token stays valid for its whole life.
  • A denylist TTL without the leeway. Revoked tokens come back for the leeway seconds.
  • Refresh tokens stored in plaintext. A database leak becomes a mass account takeover; store hashes (Access and Refresh Tokens).
  • Revocation in some services only. Enforce it in one shared place.
  • Open sockets after logout. Close them through the per-user event.
  • An unbounded denylist. Entries need a TTL; with one, the set stays small.

Interview angle 4

  • “How do you revoke a JWT?” — pure JWT: you cannot, beyond expiry. In practice: short access tokens plus revoking the refresh family; a jti denylist for single tokens; a per-user cutoff or token version for all of a user’s tokens. Each one but the first is a lookup per request.
    • Follow-up: “Your denylist key’s TTL is exp - now and the verifier allows 30 s of leeway. What’s the bug?” — the key expires before the verifier stops accepting the token, so the revoked token validates again for up to 30 s. The TTL must be exp - now + leeway.
  • “The Redis holding the denylist is down. Fail open or closed?” — decide per route: closed for admin and payments, open with an alert for low-risk reads. Better to remove the dependency: the live set is only one access TTL of revocations, small enough to replicate into every process via pub/sub.
  • “A user clicks ‘log out of all devices’ with three WebSockets open and 10 minutes left on their access tokens. What runs?” — set the per-user cutoff (or bump the token version), revoke every refresh family, publish a per-user event that closes the sockets on every node, and revoke at the IdP (RFC 7009, or back-channel logout to the other apps).
  • “Why is JWT revocation harder than session revocation?” — a session is server-side state, so deleting it is the revocation. A JWT is valid by signature alone, so revoking one means adding state back — and once every request checks a store, you have reinvented sessions.