DEV Community

Cover image for I Thought JWTs Were Stateless. Turns Out Logout Made Me Build a Stateful Layer Anyway.
Ravi Gupta
Ravi Gupta

Posted on

I Thought JWTs Were Stateless. Turns Out Logout Made Me Build a Stateful Layer Anyway.

This is Part 3 of a 4-part series on building AuthShield - a production-ready standalone authentication microservice. This post covers JWT design, the logout problem, Redis blacklisting, the two-token strategy, and refresh token rotation with reuse detection.
Part 1 is here: Why I Stopped Writing Auth Code for Every Project and Built AuthShield
Part 2 is here: I Thought OAuth Was Just Adding a Google Button. Turns Out It's a CSRF Problem Disguised as a Feature


The selling point of JWTs is that they are stateless.

The server issues a token, signs it, and forgets about it. Every subsequent request includes the token. The server verifies the signature, reads the claims, and knows everything it needs - who the user is, what roles they have, when the token expires. No database lookup. No session store. No shared state between instances.

For an auth microservice like AuthShield that needs to work across multiple downstream services, this is exactly the right foundation. Any service with the same signing secret can verify any token independently.

Then I implemented logout.


The Logout Problem

When a user logs out, what actually happens to their JWT?

Nothing. It keeps working until it expires.

A JWT is a signed string. It is not stored anywhere on the server. There is nothing to delete. If a user logs out and someone else - an attacker, a browser tab left open on a shared computer, anyone - has that token, it is still valid for the remainder of its lifetime.

That is not logout. That is the appearance of logout.

The solution is a blacklist. When a user logs out, you store the token's unique identifier in a fast lookup store and check every incoming token against it. If the token is on the blacklist, reject it regardless of the signature being valid.

AuthShield uses Redis for this, and the specific identifier used is the jti claim - JWT ID - a unique value embedded in every token at creation time.

import jwt
import uuid
from datetime import datetime, timedelta, timezone
from redis.asyncio import Redis

def create_access_token(user_id: str, email: str, roles: list[str]) -> tuple[str, str]:
    jti = str(uuid.uuid4())  # Unique token ID - used for blacklisting

    now = datetime.now(timezone.utc)
    expires_at = now + timedelta(minutes=15)

    payload = {
        "sub": user_id,
        "email": email,
        "roles": roles,
        "jti": jti,           # The blacklist key
        "type": "access",
        "iat": now,
        "exp": expires_at,
    }

    token = jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
    return token, jti  # Return both - jti needed for blacklisting on logout


async def blacklist_token(redis: Redis, jti: str, expires_at: datetime) -> None:
    # Calculate remaining lifetime of the token
    now = datetime.now(timezone.utc)
    remaining_seconds = int((expires_at - now).total_seconds())

    if remaining_seconds > 0:
        # Store in Redis with TTL matching token's remaining life
        # When the token would have expired naturally, Redis removes it automatically
        # No cleanup job needed — it is self-managing
        await redis.setex(f"blacklist:{jti}", remaining_seconds, "1")


async def is_token_blacklisted(redis: Redis, jti: str) -> bool:
    return await redis.exists(f"blacklist:{jti}") > 0
Enter fullscreen mode Exit fullscreen mode

The TTL on the Redis key is important. There is no point keeping a blacklisted token in Redis after it would have expired naturally - it is already invalid. Setting the TTL to match the remaining token lifetime means the blacklist is self-cleaning. Old entries disappear automatically without any maintenance.

Every protected request checks the blacklist before doing anything else:

async def get_current_user(
    credentials: HTTPAuthorizationCredentials = Depends(security),
    redis: Redis = Depends(get_redis),
    db: AsyncSession = Depends(get_db)
) -> User:
    token = credentials.credentials

    try:
        payload = jwt.decode(
            token,
            settings.SECRET_KEY,
            algorithms=["HS256"]  # Always specify explicitly — never trust the token's own alg header
        )
    except jwt.ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="Token expired")
    except jwt.InvalidTokenError:
        raise HTTPException(status_code=401, detail="Invalid token")

    # Check blacklist before anything else
    jti = payload.get("jti")
    if await is_token_blacklisted(redis, jti):
        raise HTTPException(status_code=401, detail="Token has been revoked")

    # Token is valid and not blacklisted — proceed
    user_id = payload.get("sub")
    return await user_repo.get_by_id(db, user_id)
Enter fullscreen mode Exit fullscreen mode

One detail worth calling out: algorithms=["HS256"] is explicit and non-negotiable. Early JWT libraries had a vulnerability where if the token header specified "alg": "none", some libraries would skip signature verification entirely. An attacker could forge any payload they wanted. Always specify exactly which algorithms you accept and reject everything else.

Now logout works. But solving logout uncovered the next problem.


The Two-Token Problem

If the logout blacklist works, why not just use a single long-lived token? Issue it on login, blacklist it on logout, done.

Because if that token is stolen, the attacker has access for the entire duration of its lifetime. A 7-day token stolen on day one gives an attacker 7 days of undetected access. A 30-day token is worse. The longer the lifetime, the larger the window of damage from theft.

So make it short-lived. 15 minutes. Now a stolen token is only useful for 15 minutes.

But now the user has to log in every 15 minutes. That is not a user experience - that is punishment.

The solution is two tokens with different purposes and different lifetimes.

An access token is short-lived - 15 minutes in AuthShield. It is sent with every API request. If it is stolen, the damage window is 15 minutes maximum.

A refresh token is long-lived - 7 days. It is used for exactly one purpose: getting a new access token when the old one expires. It is never sent to API endpoints. It never touches the application layer. It only ever goes to the /auth/refresh endpoint.

The user experience is seamless. The access token expires silently, the client uses the refresh token to get a new one, and the user never knows it happened. Security and UX are no longer in conflict.

import secrets
import hashlib

def generate_refresh_token() -> tuple[str, str]:
    # Generate a cryptographically random token
    raw_token = secrets.token_urlsafe(64)

    # Hash it before storing — same reason we hash passwords
    # If the database is breached, raw tokens are not exposed
    token_hash = hashlib.sha256(raw_token.encode()).hexdigest()

    return raw_token, token_hash  # Return raw to client, store hash in DB


async def store_refresh_token(
    db: AsyncSession,
    user_id: str,
    session_id: str,
    token_hash: str,
    family_id: str  # More on this shortly
) -> RefreshToken:
    refresh_token = RefreshToken(
        user_id=user_id,
        session_id=session_id,
        token_hash=token_hash,
        family_id=family_id,
        is_used=False,
        is_revoked=False,
        expires_at=datetime.now(timezone.utc) + timedelta(days=7),
    )
    db.add(refresh_token)
    await db.commit()
    return refresh_token
Enter fullscreen mode Exit fullscreen mode

Refresh tokens are not JWTs. They are opaque random strings, stored hashed in PostgreSQL. There is no benefit to making them JWTs because they are always verified against the database anyway - a database lookup is required regardless, so the self-contained nature of JWTs adds nothing here.

Storing them hashed mirrors the same principle as password hashing. If the database is compromised, an attacker gets hashes, not usable tokens.

Two tokens solved the UX problem. But it introduced another one: what happens if the refresh token is stolen?


Refresh Token Rotation and Reuse Detection

A 7-day refresh token is a valuable target. If an attacker gets hold of one, they can keep generating new access tokens for 7 days without the user's password. The user might log out, the access token gets blacklisted, but the attacker still has the refresh token and can get a new access token immediately.

The first defence is rotation. Every time a refresh token is used, it is immediately invalidated and a new one is issued. The client must always use the latest token. Old tokens stop working the moment they are used.

async def rotate_refresh_token(
    db: AsyncSession,
    old_token_hash: str,
    user_id: str,
    session_id: str,
    family_id: str
) -> tuple[str, str]:
    # Mark the old token as used
    old_token = await token_repo.get_by_hash(db, old_token_hash)
    old_token.is_used = True
    await db.flush()

    # Issue a new token in the same family
    new_raw_token, new_token_hash = generate_refresh_token()
    await store_refresh_token(
        db,
        user_id=user_id,
        session_id=session_id,
        token_hash=new_token_hash,
        family_id=family_id  # Same family as the old token
    )
    await db.commit()

    return new_raw_token, new_token_hash
Enter fullscreen mode Exit fullscreen mode

Rotation limits the damage window. But it does not fully solve the theft problem. If an attacker steals the refresh token and uses it before the real user does, the attacker gets a new valid token and the real user's next refresh attempt fails - their token was already rotated.

The real user gets a confusing error. The attacker continues undetected.

This is where token families solve the problem completely.

Every refresh token belongs to a family - a UUID assigned at login time and shared by all tokens in a rotation chain. When a token is rotated, the new token carries the same family ID. The chain is trackable.

The key insight: if a token that has already been marked as used is presented again, it means one of two things. Either there is a bug in the client — unlikely. Or the token was stolen and used by a second party - the real user rotated it, then the attacker tried to use the original.

When reuse is detected, the correct response is not just to reject the request. It is to revoke the entire family - every token descended from that login. Both the attacker's current token and the real user's current token become invalid simultaneously.

async def handle_refresh_token(
    db: AsyncSession,
    redis: Redis,
    raw_token: str
) -> tuple[str, str]:
    # Hash the incoming token to look it up
    token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
    token_record = await token_repo.get_by_hash(db, token_hash)

    if not token_record:
        raise HTTPException(status_code=401, detail="Invalid refresh token")

    if token_record.is_revoked:
        raise HTTPException(status_code=401, detail="Refresh token has been revoked")

    # REUSE DETECTION — this is the critical check
    if token_record.is_used:
        # This token was already rotated — someone is using an old token
        # Could be an attacker who stole it before the real user rotated it
        # Revoke the entire family — both attacker and real user are logged out
        await token_repo.revoke_family(db, token_record.family_id)
        await db.commit()
        raise HTTPException(
            status_code=401,
            detail="Token reuse detected. All sessions have been revoked."
        )

    # Token is valid — rotate it
    if token_record.expires_at < datetime.now(timezone.utc):
        raise HTTPException(status_code=401, detail="Refresh token expired")

    new_raw_token, _ = await rotate_refresh_token(
        db,
        old_token_hash=token_hash,
        user_id=token_record.user_id,
        session_id=token_record.session_id,
        family_id=token_record.family_id
    )

    # Issue a new access token
    user = await user_repo.get_by_id(db, token_record.user_id)
    new_access_token, _ = create_access_token(
        user_id=str(user.id),
        email=user.email,
        roles=[role.name for role in user.roles]
    )

    return new_access_token, new_raw_token
Enter fullscreen mode Exit fullscreen mode

The outcome of reuse detection is uncomfortable for the real user - they get logged out and have to re-authenticate. But that is the correct response. Something is wrong. Either the token was stolen, or the client has a bug. Either way, forcing re-authentication is the right call. The real user is inconvenienced for 30 seconds. The attacker loses access entirely.


What Lives Where and Why

After working through all of this, the storage decisions become clear.

Access tokens live in client memory - never in localStorage, which is accessible to any JavaScript on the page including third-party scripts. Memory is cleared when the tab closes, which is acceptable given the 15-minute TTL.

Refresh tokens live in PostgreSQL, stored as SHA-256 hashes. The raw token is given to the client once and never stored on the server. If the database is breached, the hashes are useless without the raw tokens.

The blacklist lives in Redis, keyed by JTI with TTLs matching each token's remaining lifetime. Self-cleaning, fast, and adds under 1 millisecond to every protected request.


What JWT Security Actually Is

Working through all four of these problems changed how I think about token security.

The stateless property of JWTs is real and valuable - it is what makes AuthShield's tokens verifiable by any downstream service without calling back to AuthShield. But statelessness and revocability are fundamentally in conflict. The solution is not to pick one. It is to understand which layer handles which concern.

JWTs handle identity and authorization - who the user is and what they can do. Redis handles revocation - making logout immediate. PostgreSQL handles refresh token lifecycle - rotation, family tracking, and theft detection. Each layer does one thing.

Understanding that separation is what made the implementation make sense.


What Is Next

Next week: rate limiting, integration testing against real infrastructure, Docker, and what I found out about the gap between working locally and running in production.

I thought finishing the code meant I was done. It did not.

AuthShield on GitHub: AuthShield-Repository

Always learning, always observing.

Top comments (0)