The Promise We Were All Sold
When JWT (JSON Web Token) authentication first became popular, the pitch was irresistible. "Stateless authentication!" the articles screamed. "No more session stores! No more Redis! No more shared state between your servers!" And honestly, it sounded incredible. I remember the first time I ripped out a session database from a project and replaced it with JWTs — it felt like I'd finally cracked the code on scalable auth. The server would just verify a cryptographic signature, extract the user data baked right into the token, and move on. Clean. Simple. Beautiful.
The idea was modeled on something like a wristband at a concert. The venue stamps your hand, and every staff member at every door can verify that stamp without calling headquarters. No central list. No lookup. Just trust the stamp. JWTs work the same way — the server signs the token, and any server with the right secret key can verify it independently. That's genuinely powerful, and the core concept is sound.
But here's what nobody warns you about: the moment your system grows past "hello world," you start bolting on requirements that quietly drag state right back into your architecture. You don't even notice it happening. One day you're running a beautiful stateless system, and the next day you've got a Redis cluster, a database table, and three microservices all working together just to handle authentication. You've rebuilt the session store. You just named it differently.
Where the Cracks Start Showing
The first crack usually appears when a user asks: "Can I log out?" Seems simple. But think about what logout actually means in a stateless system. You gave that user a token with a 24-hour expiration. They click logout. You delete the token from the client's storage. Done, right? Except that token is still valid. If someone intercepted it, copied it, or if the user logged out on their phone but an attacker grabbed the token from an earlier request, that token works perfectly fine until its expiration timestamp passes.
So you build a token blacklist. A table in your database, or a set in Redis, where you store the IDs of tokens that have been explicitly revoked. Now every single request hits that store to check whether the token is on the naughty list. Sound familiar? That's a session store. You've just given it a different name. The server is now maintaining state — specifically, the state of "which tokens are no longer valid." I've found that this realization hits teams hard, usually in a code review where someone asks, "wait, why are we hitting Redis on every request if we switched to JWTs to avoid that?"
The second crack comes from security requirements. What happens when a user changes their password? A responsible system should invalidate all their existing sessions. What about when an admin suspends an account? What about when you discover a token was compromised and you need to kill it immediately? All of these scenarios require some form of centralized state, because the token itself has no way of knowing that circumstances have changed. It's a signed piece of data frozen in time. The world moves on; the token doesn't.
The Refresh Token Rabbit Hole
Refresh tokens are where things get genuinely complicated. The standard pattern is to issue a short-lived access token (say, 15 minutes) and a long-lived refresh token (days or weeks). The access token is what you send with API requests. When it expires, you use the refresh token to get a new access token without making the user log in again. This is good practice. Short-lived access tokens limit the damage of a leaked token.
But refresh tokens have to be stateful. There's no way around it. You need to store refresh tokens so you can validate them, rotate them, and revoke them. You need to know which refresh tokens belong to which users. You need to detect refresh token reuse, which is a real attack vector where a stolen refresh token gets used by an attacker before the legitimate user notices. Implementing refresh token rotation — where each use of a refresh token issues a new one and invalidates the old — requires tracking the entire chain of tokens server-side.
Here's a simplified example of what server-side refresh token rotation looks like:
// Express.js example of refresh token rotation
const express = require('express');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
// Simulating a database store for refresh tokens
const refreshTokenStore = new Map();
function generateTokens(userId) {
const accessToken = jwt.sign(
{ userId, type: 'access' },
process.env.ACCESS_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = crypto.randomBytes(64).toString('hex');
// Store the refresh token with its associated user — this is STATE
refreshTokenStore.set(refreshToken, {
userId,
createdAt: Date.now(),
used: false
});
return { accessToken, refreshToken };
}
app.post('/refresh', (req, res) => {
const { refreshToken } = req.body;
const storedToken = refreshTokenStore.get(refreshToken);
if (!storedToken) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Detect refresh token reuse — a sign of compromise
if (storedToken.used) {
// Invalidate ALL tokens for this user — possible breach
// This requires knowing all tokens for the user, more state!
console.warn(`Refresh token reuse detected for user ${storedToken.userId}`);
return res.status(401).json({ error: 'Token reuse detected' });
}
// Mark old token as used (rotation)
storedToken.used = true;
refreshTokenStore.set(refreshToken, storedToken);
// Issue new token pair
const newTokens = generateTokens(storedToken.userId);
return res.json(newTokens);
});Notice the refreshTokenStore. That's your state. Every serious JWT implementation I've encountered in production has something equivalent to this, whether it's a Redis set, a PostgreSQL table, or a DynamoDB entry.
The Permission Change Problem
Here's a scenario that kills the "pure stateless" dream completely. Imagine you're running a SaaS product. A user on the free tier upgrades to a paid plan. In a stateless JWT world, their token still says plan: "free" until it expires. You either have to wait up to 15 minutes for their access token to naturally expire and get refreshed (frustrating user experience), or you need a mechanism to force-invalidate the old token immediately.
The same problem shows up when you demote a user's role, ban an account, or change permissions. Any claim baked into the JWT becomes stale the moment reality diverges from what the token says. Real systems deal with this in a few ways. Some store a tokenVersion counter in the database and embed it in the JWT — if the counter in the database doesn't match the counter in the token, the token is rejected. Others use short expiration times and accept the brief window of inconsistency as a calculated tradeoff.
Here's how the token version approach looks in Python with FastAPI:
from fastapi import FastAPI, Depends, HTTPException
from fastapi.security import HTTPBearer
import jwt
from datetime import datetime, timedelta
import databases
app = FastAPI()
security = HTTPBearer()
# Simulated database call to get user's current token version
async def get_user_token_version(user_id: str) -> int:
# In reality, this hits your database
# SELECT token_version FROM users WHERE id = ?
user = await database.fetch_one(
"SELECT token_version FROM users WHERE id = :id",
values={"id": user_id}
)
return user["token_version"]
async def verify_token(credentials = Depends(security)):
token = credentials.credentials
try:
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=["HS256"]
)
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
user_id = payload.get("user_id")
token_version = payload.get("token_version")
# THIS is the state lookup — every request hits the database
current_version = await get_user_token_version(user_id)
if token_version != current_version:
raise HTTPException(
status_code=401,
detail="Token has been invalidated — please log in again"
)
return payload
@app.get("/protected")
async def protected_route(user = Depends(verify_token)):
return {"message": f"Hello, {user['user_id']}"}Every single protected request now hits the database to check that version number. The "no database on auth" dream is over.
So Is JWT Just Bad?
Absolutely not. I want to be clear here because it's easy to read everything above and conclude that JWTs are a broken idea. They're not. They're a misunderstood idea. JWTs are exceptional for specific use cases, and genuinely problematic when people treat them as a magic solution to all authentication complexity.
JWTs shine in service-to-service communication within a trusted network. If your API gateway validates the token once and then passes user identity claims to downstream microservices as headers, those services never need to hit a database. They trust the gateway's verification. That's real, legitimate statelessness, and it dramatically reduces internal network overhead. JWTs are also great for short-lived, single-purpose tokens — email verification links, password reset flows, one-time access tokens. These don't need revocation because they expire quickly and serve a narrow purpose.
The key mental shift is this: stop thinking of "stateless" as an absolute property of your entire auth system, and start thinking of it as a characteristic of individual verification steps. The gateway can be stateful (checking a token blacklist, validating a refresh token store). The downstream services can be stateless (trusting the claims in the forwarded, already-verified JWT). This hybrid approach is how mature architectures actually work.
What You Should Actually Do
Accept the state. That's the first thing. Stop fighting against the fundamental requirements of your system and pretending you can avoid persistence. If you need logout, you need a blacklist or very short expiration times. If you need immediate permission changes, you need either a token version check or short tokens. Name your state honestly and manage it deliberately rather than accumulating it accidentally.
Use short access token lifetimes aggressively. 15 minutes is a reasonable default. Some high-security systems go as low as 5 minutes. Yes, this means refresh token requests happen more often, but refresh tokens can be tightly controlled and rotated. The shorter your access token, the smaller the window of damage if one is compromised, and the faster permission changes propagate naturally.
Consider whether you actually need JWTs at all for your primary web authentication. For traditional web apps with a modest number of concurrent users, a well-implemented session store backed by Redis is less complex, easier to revoke, easier to reason about, and honestly more secure by default. JWTs add value when you have distributed systems, mobile clients, third-party API consumers, or cross-domain authentication needs. If none of those apply to you, you might be using JWTs because they're trendy, not because they solve a real problem you have.
The Audit Moment
I've found that the most useful exercise is to sit down with your team and honestly audit every piece of state your "stateless" JWT system is currently managing. Write it all down. The refresh token table. The blacklist. The version numbers in your user table. The Redis cache where you store user profile data to avoid database hits. The session-like data you're tucking into cookies alongside the JWT.
Most teams I've talked through this exercise with are genuinely surprised by the list. Not because they did anything wrong — they made sensible engineering decisions to meet real requirements — but because nobody ever stepped back and looked at the whole picture at once. When you see it all laid out, you can make intentional choices about where your state lives, how it's managed, and whether the complexity of JWTs is actually buying you anything compared to a simpler session-based approach for your specific context.
The goal was never "zero state." The goal was scalable, secure authentication. Sometimes JWTs get you there. Sometimes they're overhead you don't need. The engineers who build the best auth systems are the ones who understand both tools well enough to know which one fits the problem — not the ones who picked a side and defended it religiously.