The JWT vs session debate is older than 2026 but the answer changed less than people think. The right choice depends on your app shape; “stateless tokens” isn’t an automatic win. This post is the working comparison.
Session cookies
@app.post("/login")
async def login(creds):
user = await authenticate(creds)
session_id = secrets.token_urlsafe(32)
await redis.set(f"sess:{session_id}", json.dumps({"user_id": user.id}), ex=86400 * 30)
return RedirectResponse("/", headers={"set-cookie": f"sid={session_id}; HttpOnly; Secure; SameSite=Lax"})
@app.middleware("http")
async def auth(request, call_next):
sid = request.cookies.get("sid")
if sid:
data = await redis.get(f"sess:{sid}")
if data:
request.state.user = json.loads(data)
return await call_next(request)
Server stores session; cookie holds opaque ID. Standard since the dawn of time.
JWT
@app.post("/login")
async def login(creds):
user = await authenticate(creds)
token = jwt.encode({"sub": user.id, "exp": datetime.utcnow() + timedelta(hours=1)}, SECRET, algorithm="HS256")
return {"access_token": token}
# Verify
def get_user(token: str = Header(alias="Authorization")):
token = token.removeprefix("Bearer ")
payload = jwt.decode(token, SECRET, algorithms=["HS256"])
return payload["sub"]
Self-contained; no server lookup needed.
When sessions win
- Same-origin web app: simplest path.
- Revocation matters: ban a user; delete session; done.
- Refresh token complexity unwanted.
- Cookie can be HttpOnly (XSS-safer than localStorage).
For 80% of webapps: sessions.
When JWT wins
- Cross-domain API: cookies don’t cross domains cleanly.
- Mobile app: cookies awkward; bearer tokens natural.
- Microservices: each service verifies independently without shared session store.
- Scale: stateless verification scales horizontally.
- Standard: OAuth / OIDC use JWTs.
For service-to-service or mobile-first APIs: JWT.
Hybrid (most common in real apps)
First-party web: session cookie.
Mobile / API: JWT (refresh + access).
Service-to-service: JWT signed with internal key.
Pragmatic. Each surface gets the right tool.
JWT pitfalls
1. Storage in localStorage
XSS reads it; full account compromise. HTTP-only cookie better.
// BAD
localStorage.setItem("token", token);
// BETTER
// Set as HttpOnly cookie from server
2. No revocation
User leaves company; their JWT is valid until expiry. Mitigations:
- Short TTL (5-15 min).
- Refresh tokens (longer, revocable).
- Token version in DB; check on every request (now stateful — defeats main JWT advantage).
3. None algorithm
Old JWT bug: alg: none accepted by some libraries. Always pin algorithm:
jwt.decode(token, SECRET, algorithms=["HS256"]) # explicit
4. Public claims
JWTs are signed, not encrypted. Anyone with the token can read it. Don’t put sensitive data in claims.
5. Algorithm confusion
Server expects HS256; attacker sends RS256-signed with public key as HMAC secret → accepted. Pin algorithm.
Refresh + access pattern
Access token: 15 min, JWT.
Refresh token: 30 days, opaque, server-stored.
@app.post("/refresh")
async def refresh(refresh_token: str):
record = await db.fetchrow("SELECT * FROM refresh_tokens WHERE token = $1 AND revoked = false", refresh_token)
if not record or record.expires_at < now(): raise InvalidToken
new_access = jwt.encode({"sub": record.user_id, "exp": now() + 15 * 60}, SECRET, "HS256")
return {"access_token": new_access}
Access tokens are short-lived JWTs (no revocation needed). Refresh tokens are long-lived, server-stored, revocable.
For revocation: revoke refresh token; access token expires within 15 min.
CSRF protection
Sessions in cookies are CSRF-vulnerable; protect with:
- SameSite=Lax (default in browsers): blocks cross-site form posts.
- CSRF token: validated on mutation.
JWTs in Authorization header aren’t CSRF-vulnerable (cross-site requests can’t add custom headers).
XSS protection
If JWT is in localStorage: XSS = compromise. HttpOnly cookies are safer.
For cookies (session OR JWT-in-cookie):
- HttpOnly.
- Secure.
- SameSite=Lax or Strict.
OAuth / OIDC
If you’re integrating Google / GitHub / etc. login: you receive JWTs (ID tokens) from those providers. You still choose what session shape your own app uses.
Common: receive OIDC token → create your own session (for first-party UI) and/or your own JWT (for API).
See Authentication 2026 .
Passkeys
WebAuthn / passkeys complement either choice. Authentication flow uses passkey; resulting session can be cookie or JWT.
Passkeys are the auth method; sessions/JWTs are the post-auth state. Don’t conflate.
Service-to-service
Service A → Service B
Authorization: Bearer <signed JWT with internal key>
JWT signed by A’s private key; B verifies with A’s public key. Standard for internal APIs.
For service mesh (Istio): mTLS handles this; JWT optional.
Common mistakes
1. JWT for first-party web
Adds complexity. Sessions are simpler.
2. Long JWT TTL with no refresh
Stolen JWT works for hours. Short TTL + refresh.
3. JWT in localStorage + XSS
Game over for compromised users. HttpOnly cookies.
4. No algorithm pin
Algorithm confusion attack. Always pin.
5. Big payloads
JWT in cookie/header on every request. 4KB JWT = 4KB per request overhead. Keep claims minimal.
What I’d ship today
For first-party web:
- Session cookie (HttpOnly, SameSite=Lax).
- Redis-backed session store.
- Passkey / WebAuthn as auth method.
For mobile / cross-domain API:
- Refresh + access JWT pattern.
- Refresh in HttpOnly cookie (or secure storage).
- Access in memory (or short-lived storage).
For service-to-service:
- JWT signed with service key, or mTLS via mesh.
Mix freely.
Read this next
If you want my session + JWT hybrid auth reference, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .