</>CodeWithKarani

JWTs Have No Logout: Adding Expiry and Revocation Without Losing Statelessness

Karani GeoffreyKarani Geoffrey9 min read

Someone reports their laptop stolen. You open the admin panel, click deactivate on their account, feel responsible, and move on. Two hours later that laptop is still pulling data from your API, because the thief never needed the account. They had the token, and your token does not care that the account is disabled. It was signed, it has not expired, and your verifier has no opinion beyond those two facts.

This is the part of JWT authentication that the tutorials skip. They show you how to issue a token and how to verify it, and they tell you, correctly, that the appeal of a JWT is that you can verify it without a database lookup. What they do not tell you is that this is the same sentence as "you cannot revoke it without a database lookup". A stateless token is a token you cannot turn off.

So the honest framing: a JWT you cannot revoke is a password you cannot change. If it is the only thing standing between an attacker and your data, and you have no way to invalidate it before it expires, then logout is a lie and a stolen token is valid until the heat death of your exp claim, which for a lot of codebases is "never".

do not try to make one long-lived token do everything. Split the job.

  • Give the access token a short exp (5 to 15 minutes) and keep it stateless. You accept that you cannot revoke it individually because it dies quickly on its own.
  • Give each session a refresh token that is stored and looked up server-side, rotated on every use, and deleted on logout. This is the thing you actually revoke.
  • Logout = delete the refresh token record. The access token then expires within minutes and cannot be renewed.
  • If you must kill an access token right now, keep a small denylist of jti values and check it on verify. That is a deliberate, scoped return to statefulness, not a failure.
  • A single long-lived JWT with no exp and no server record is the anti-pattern. If that is what you have, this is a security bug, not a style choice.

Why "JWTs are stateless, that is the point" leads people off a cliff

The advice is not wrong, it is incomplete, and the missing half is where the damage lives. Statelessness is a genuine benefit for one specific operation: verifying an access token on a hot request path without touching a database or a cache. At a few thousand requests a second that saved round trip is real.

The mistake is generalising "I do not need state to verify" into "I do not need state at all". Verification and revocation are different questions. Verification asks "was this token signed by me and is it still within its lifetime". Revocation asks "have I decided, since I issued this, that it should no longer work". The signature and the exp claim can answer the first question offline forever. Nothing inside the token can answer the second, because the token was frozen at issue time and the decision to revoke happens later.

So the only two levers you have over a pure stateless token are both set at issue time: how long it lives, and nothing else. That is why the lifetime has to be short. The short lifetime is your revocation mechanism, a very blunt one that reaches every token at once after a fixed delay.

One long-lived JWT: logout changes nothing issued user clicks logout token finally expires (days) still fully valid, attacker keeps using it Short access token + revocable refresh token issued logout deletes refresh token access token expires, no renewal dead within minutes
The gap between logout and "actually revoked" is exactly your access-token lifetime. Make it small on purpose.

The pattern that actually gives you logout

Two tokens, two jobs. This is the standard OAuth-shaped design and it exists precisely because a single token cannot be both cheap to verify and easy to revoke.

Step 1: make the access token short-lived and stateless

Set exp to minutes and put only what you need for authorisation in the claims. This token is never stored server-side and never revoked individually. Its safety comes entirely from its short life.

import jwt, time, uuid

ACCESS_TTL = 600  # 10 minutes, in seconds

def issue_access_token(user_id: str, session_id: str) -> str:
    now = int(time.time())
    return jwt.encode(
        {
            "sub": user_id,
            "sid": session_id,      # links back to the refresh/session record
            "jti": str(uuid.uuid4()),  # unique id, needed if you add a denylist
            "iat": now,
            "exp": now + ACCESS_TTL,
        },
        PRIVATE_KEY,
        algorithm="RS256",
    )

Verification stays a pure signature-and-expiry check, with the algorithm pinned. If you are not pinning the algorithm, fix that first: read JWT alg:none and RS256/HS256 confusion before anything here, because an unpinned verifier makes the rest of this pointless.

Step 2: issue a refresh token you actually store

The refresh token is opaque to the client. Server-side you keep a row per session: the token (hashed, never in plaintext), the user, an expiry measured in days or weeks, and a revoked flag. This row is your session. It is the state a pure JWT refused to keep, and it is small.

CREATE TABLE refresh_tokens (
    id           UUID PRIMARY KEY,
    user_id      UUID NOT NULL,
    token_hash   TEXT NOT NULL,        -- sha256 of the random token, not the token
    expires_at   TIMESTAMPTZ NOT NULL,
    revoked_at   TIMESTAMPTZ,          -- NULL means active
    replaced_by  UUID,                 -- set when rotated, for reuse detection
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON refresh_tokens (user_id);

Hash the token before storing it for the same reason you hash passwords: a leaked database dump should not hand out live sessions.

Step 3: rotate the refresh token on every use

When the client presents a refresh token to get a new access token, you issue a new refresh token and mark the old one replaced. This is rotation, and it buys you theft detection almost for free: if a refresh token that has already been rotated is presented again, either the legitimate client and an attacker both have it, or someone replayed a stolen one. Treat that as a compromise and revoke the whole chain.

def refresh(presented_token: str):
    row = find_by_hash(sha256(presented_token))
    if row is None or row.expires_at < now():
        raise Unauthorized("invalid or expired refresh token")

    if row.revoked_at is not None:
        # A revoked token was reused: assume theft, kill the whole session family.
        revoke_all_for_user(row.user_id)
        raise Unauthorized("refresh token reuse detected")

    # Rotate: revoke this one, mint its replacement.
    new_row = create_refresh_token(row.user_id)
    mark_revoked(row.id, replaced_by=new_row.id)

    access = issue_access_token(row.user_id, new_row.id)
    return access, new_row.plaintext_token

This is exactly the failure mode I dug into for a different stack in the next-auth rotation article: rotation only works if the client actually persists the new token it gets back. Drop it and you log the user out every few minutes.

Step 4: make logout delete the refresh token

def logout(session_id: str):
    revoke_by_session(session_id)   # sets revoked_at = now()
    # nothing to do about the access token: it expires within ACCESS_TTL

That is real logout. The refresh token is dead, so no new access tokens can be minted for that session, and the current access token stops working within its short lifetime. "Log out everywhere" is the same query without the session filter: revoke every refresh token for the user.

The denylist: when minutes is too long

Sometimes a ten-minute window is unacceptable. A fired employee, a token you saw in a leaked log, a compromised admin. For those cases you want to kill a specific access token now, and the tool is a denylist keyed on the jti claim.

On logout or revocation, write the token's jti and its exp into a fast store, ideally Redis with a TTL set to the token's remaining lifetime so the entry cleans itself up. On every verify, after the signature passes, check the denylist.

import redis
r = redis.Redis()

def deny(jti: str, exp: int):
    ttl = max(exp - int(time.time()), 1)
    r.setex(f"deny:{jti}", ttl, "1")

def verify_access(token: str) -> dict:
    claims = jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"])
    if r.exists(f"deny:{claims['jti']}"):
        raise Unauthorized("token revoked")
    return claims

Yes, this adds a lookup to the hot path and yes, that is the statelessness you were promised, gone. That is the trade, and it is a fine one. The denylist stays tiny because entries expire when the tokens would have expired anyway, so it never grows unbounded, and a single Redis EXISTS against short keys is cheap. You have reintroduced exactly as much state as you need to answer "has this been revoked" and not one byte more.

ApproachRevocation speedCost on verifyUse when
Short-lived access token onlyUp to the TTL (minutes)None (stateless)Default. Good enough for most apps.
+ Revocable refresh tokenRefresh blocked immediately; access token dies at TTLNone on access; one lookup on refreshYou need real logout and session control. This is the baseline.
+ jti denylistImmediate, even for the access tokenOne cache lookup per requestYou must kill a live token now: compromise, offboarding.
Long-lived JWT, no revocationNever (until exp, which may be never)NoneNever, if it is your only session token.

Verification: prove logout actually works

Do not trust the UI. Capture a real access token, log out, and hit a protected endpoint with the old token.

# 1. Log in, keep the access token
ACCESS=$(curl -s -X POST https://api.example.com/login \
  -d '{"email":"me@example.com","password":"..."}' \
  -H 'Content-Type: application/json' | jq -r .access_token)

# 2. It works
curl -s -o /dev/null -w "%{http_code}\n" \
  -H "Authorization: Bearer $ACCESS" https://api.example.com/me
# expect: 200

# 3. Log out (this must revoke the refresh token server-side)
curl -s -X POST https://api.example.com/logout -H "Authorization: Bearer $ACCESS"

# 4. Try to refresh with the old refresh token -> must fail
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://api.example.com/refresh \
  -H "Cookie: refresh_token=$OLD_REFRESH"
# expect: 401

Step 4 is the one that matters. If refresh still returns a new access token after logout, your logout is cosmetic. If you added a denylist, also confirm step 2 flips to 401 immediately after logout rather than minutes later.

What people get wrong

"We clear the token from localStorage on logout, so we are fine." You cleared your own copy. You did nothing to the copy an attacker made, or the copy sitting in a proxy log. Client-side deletion is housekeeping, not security. And storing the token in localStorage is its own problem: any XSS reads it, as covered in storing JWTs in localStorage turns any XSS into account takeover.

Setting exp to weeks "so users do not get logged out". That is not a convenience feature, it is your revocation window set to weeks. Keep the access token short and solve the re-login annoyance with a refresh token, which is exactly what it is for.

Putting a mutable role or permission set in the access token and never re-checking it. If you demote an admin, their existing access token still says admin until it expires. Short lifetimes limit the blast radius; for instant effect on privilege changes you need the denylist or a permission check that reads current state, not the frozen claim.

Storing refresh tokens in plaintext. A refresh token is a long-lived credential. Hash it in the database. A read-only leak of that table should not be a mass account takeover.

Rotating without reuse detection. Rotation without acting on a replayed old token throws away the best signal you get that a token was stolen. The revoke-the-whole-family response in step 3 is the point of rotating at all.

When it is still broken

  • Logout works in one service but not another. If several services verify the same access token independently, a denylist has to be shared (central Redis), not per-instance. A local in-memory denylist revokes on one pod and nowhere else.
  • Refresh returns 401 even for legitimate users. Almost always the client is not saving the rotated refresh token, so the next refresh presents an already-rotated one and trips reuse detection. Log which token id was presented versus what is current.
  • Denylist grows without bound. You forgot the TTL. Every denylist entry must expire no later than the token's own exp; after that the token is dead anyway and the entry is dead weight.
  • Old tokens survive a suspected breach. For a real compromise, rotate the signing key. Every access token signed with the old key becomes unverifiable at once, which is the one big hammer stateless tokens do give you.

The canonical reference is RFC 8725, JWT Best Current Practices. It is short, and it says in more formal language what this article says: decide the lifetime deliberately, and do not pretend a token you cannot revoke is a session you can end.

Frequently asked questions

Can you actually log a user out with a JWT?
Not the access token itself, and that is the whole problem. A signed JWT stays valid until it expires no matter what your server does, because verification only checks the signature and the expiry claim, not any server-side state. Real logout means two things: keep the access token short enough that it dies on its own within minutes, and revoke the long-lived refresh token server-side so no new access tokens can be minted. Clearing the cookie on the client is cosmetic; the token is still valid if anyone copied it.
Should a JWT have an expiry, and how long?
Yes, always set an exp claim. For an access token that cannot be individually revoked, minutes is the right order of magnitude: 5 to 15 minutes is common. That window is the maximum time a stolen or logged-out token stays usable, so you are trading it directly against how often the client has to refresh. Long-lived access tokens (hours or days) with no revocation path are the dangerous pattern this article is about.
What is the difference between a denylist and refresh token rotation?
A denylist (blocklist) stores the IDs (jti) of tokens you want to reject before they expire, and every verify checks that small set, so it is a partial return to stateful sessions used only for revocation. Refresh token rotation instead keeps access tokens stateless and short-lived, and puts all the server-side state in a separate refresh token you look up and can revoke on logout. Rotation is the cleaner default; a denylist is a pragmatic patch when you need to kill a specific access token immediately.
Is it wrong to store session state on the server if I use JWTs?
No. The claim that JWTs must be fully stateless is a preference, not a rule. Storing revocable refresh tokens, or a short denylist of compromised access-token IDs, is a normal and recommended design. You keep the stateless part where it pays off (verifying the access token on every request without a database hit) and add just enough state to answer the one question a pure JWT cannot: has this session been ended.
#JWT#Authentication#Refresh Tokens#Sessions#Application Security
Keep reading

Related articles