Why JWT for Sessions Is Usually Wrong
TL;DR — JWT shines for service-to-service auth and federated identity. For first-party web app sessions, server-side sessions in Redis are simpler, securer, more revocable. The “stateless = better” pitch oversimplifies; statefulness has real ops costs but real security wins.
After JWT done right, the related question: should you use JWT at all? For a lot of web apps, the answer is no.
The “stateless” argument
The classic pitch for JWT:
- No session state on the server
- Horizontally scalable without sticky sessions
- Works across services without shared session storage
These are real benefits. Real costs come with them.
Where JWT genuinely wins
Federated identity. User logs in at Auth0; API trusts the JWT. Auth0 manages users; API just verifies. JWT is the contract.
Service-to-service. Backend service A calls backend service B. JWT (or its cousin, OIDC ID tokens) carries identity across the call. Stateless suits this.
Many independent services consuming the same auth. 10 microservices each verifying JWT vs 10 microservices all hitting a session service. JWT wins on operational simplicity.
Mobile / native apps with offline capability. A JWT works offline (until it expires). A session cookie doesn’t.
Where JWT loses
First-party web app with one backend. Your monolith / cluster, your sessions, your users. The benefits of JWT (no shared state) don’t apply because you ALREADY have shared state (the database). Adding sessions to Redis is incremental.
Costs of JWT here:
- Hard to revoke. Logout, password change, account suspension — none invalidates an outstanding JWT. Workarounds (denylist, short expiry + refresh) reintroduce the state you were trying to avoid.
- Bigger requests. Every request carries the full JWT (~500 bytes vs ~50 bytes for a session ID).
- Subtler bugs. Library defaults, algorithm confusion, kid handling — server-side sessions are simpler to get right.
- Stale data. If user roles change, the JWT still reflects old roles until expiry.
Server-side sessions: the alternative
Client → POST /login → server
↓
Generate random ID
Store in Redis: session:abc123 = {user_id: 42, expires: ...}
↓
Set-Cookie: session=abc123 (HttpOnly, Secure, SameSite=Lax)
↓
Client ← cookie set
Client → GET /api → cookie sent
↓
Server looks up session:abc123 in Redis
Validates expiry
Sets req.user
Proceeds
Properties:
- Session ID is opaque — no info leak
- Server-side state — change session = revoke any time
- Cookies handle by browser correctly (auto-send, can be HttpOnly so JS can’t read)
- Redis lookup ~1ms; negligible
A real comparison
Same web app, two implementations:
JWT version:
# Login
def login(email, password):
user = users.find(email)
if not check_password(password, user.password_hash):
raise Unauthorized()
token = jwt.encode({
"sub": user.id,
"exp": now + 3600,
"iat": now,
}, JWT_SECRET, "HS256")
return {"token": token}
# Middleware
def auth_middleware(request):
token = request.headers.get("Authorization", "").removeprefix("Bearer ")
try:
claims = jwt.decode(token, JWT_SECRET, ["HS256"])
request.user = users.get(claims["sub"])
except jwt.InvalidTokenError:
raise Unauthorized()
# Logout
def logout():
return {"ok": True} # JWT can't be revoked; client just discards
The logout is the problem. The JWT still works until expiry.
Session version:
# Login
def login(email, password):
user = users.find(email)
if not check_password(password, user.password_hash):
raise Unauthorized()
sid = secrets.token_urlsafe(32)
redis.setex(f"session:{sid}", 3600, json.dumps({"user_id": user.id}))
response.set_cookie("session", sid, httponly=True, secure=True, samesite="lax")
return {"ok": True}
# Middleware
def auth_middleware(request):
sid = request.cookies.get("session")
data = redis.get(f"session:{sid}")
if not data: raise Unauthorized()
request.user = users.get(json.loads(data)["user_id"])
# Logout
def logout():
sid = request.cookies.get("session")
if sid: redis.delete(f"session:{sid}")
response.delete_cookie("session")
return {"ok": True}
Logout actually works. Password change can iterate all sessions and revoke. Account suspension immediately effective.
Hybrid pattern: opaque tokens
For mobile / native clients where cookies aren’t ideal, use opaque tokens that look like JWTs but aren’t:
client → /login → server returns "tok_abc123xyz"
client → GET /api with Authorization: Bearer tok_abc123xyz
server → looks up tok_abc123xyz in Redis/DB → finds user
Server-side state, but token is in header (works for non-browser clients). Best of both for many cases.
When to actually use JWT for sessions
If you have:
- 10+ backend services that all need to verify auth
- Federated identity (Auth0 / Clerk / your own SSO)
- Need to verify without DB call (latency-critical)
…JWT pulls weight. Otherwise: sessions are simpler.
The migration
Migrating from JWT to sessions isn’t usually done; the cost is comparable. Migrating from sessions to JWT is what teams sometimes do when they outgrow single-service architecture.
For new apps in 2022: default to sessions. Move to JWT when you have the architectural reason.
Common Pitfalls
JWT in localStorage. XSS-readable. Cookies + HttpOnly are safer.
Long-lived JWT, no refresh. No revocation. Hours-of-attack window after a breach.
“JWT for everything” because tutorial said so. Tutorials simplify. Real apps benefit from sessions.
Session table without expiry / cleanup. Grows forever. Set TTL in Redis or expire cleanup.
Sessions without HttpOnly + Secure. Cookie stealing via XSS or sniffing. Both flags mandatory.
Different session lifetime in code vs cookie. Server thinks it’s valid, browser deleted it (or vice versa). Set both consistently.
Wrapping Up
For most first-party web apps: server-side sessions in Redis. JWT for federation, multi-service, mobile. Wednesday: refresh tokens and revocation patterns — making JWT bearable when you do need it.