JWT vs Sessions for APIs
The JWT vs sessions debate generates more heat than almost any backend architecture decision, and most of it misses the point. Neither is universally better — they're optimized for different constraints. JWTs shine when you need stateless verification across services; sessions shine when you need instant revocation and simpler mental models. I've built auth for mobile apps (JWT + refresh), internal admin tools (sessions + cookies), and microservice platforms (JWT with short TTL). The wrong choice isn't picking JWT or sessions — it's picking one without understanding what you're trading.
How each works
Session-based:
Login → Server creates session ID → Stores in Redis/DB
→ Returns session cookie to client
Request → Client sends cookie → Server looks up session → Authorizes
Logout → Server deletes session → Immediate revocation
JWT-based:
Login → Server signs JWT with claims → Returns token to client
Request → Client sends Authorization: Bearer <jwt> → Server verifies signature → Authorizes
Logout → Client deletes token → Token still valid until expiry
Comparison
| Factor | Sessions | JWT |
|---|---|---|
| Revocation | Instant (delete session) | Delayed (wait for expiry) |
| Scalability | Needs shared session store | Stateless verification |
| Server memory | Session store required | None (stateless) |
| Token size | Small (session ID) | Larger (claims embedded) |
| Cross-service | Requires session lookup | Self-contained verification |
| Mobile apps | Awkward (cookies) | Natural (Bearer header) |
| XSS risk | HttpOnly cookies mitigate | localStorage is vulnerable |
JWT pattern for mobile/SPA
Short-lived access + refresh rotation:
# Login
access_token = create_jwt(user_id, expiry=timedelta(minutes=15))
refresh_token = generate_opaque_token()
store_refresh_token(user_id, refresh_token, expiry=timedelta(days=30))
return {"access_token": access_token, "refresh_token": refresh_token}
# Refresh
def refresh(refresh_token):
stored = lookup_refresh_token(refresh_token)
if not stored or stored.expired:
raise Unauthorized()
delete_refresh_token(refresh_token) # rotate — old token invalid
new_refresh = generate_opaque_token()
store_refresh_token(stored.user_id, new_refresh)
new_access = create_jwt(stored.user_id, expiry=timedelta(minutes=15))
return {"access_token": new_access, "refresh_token": new_refresh}
Access token: 15 minutes. Refresh token: 30 days, rotated on use, stored server-side. Stolen refresh token is detectable (rotation fails on reuse).
For mobile, see OAuth PKCE patterns for the authorization flow.
Session pattern for web apps
@app.post("/login")
def login(credentials):
user = authenticate(credentials)
session_id = secrets.token_urlsafe(32)
redis.setex(f"session:{session_id}", 86400, json.dumps({"user_id": user.id}))
response.set_cookie("session", session_id, httponly=True, secure=True, samesite="Strict")
return response
@app.get("/api/data")
def get_data(request):
session = redis.get(f"session:{request.cookies['session']}")
if not session: raise Unauthorized()
user_id = json.loads(session)["user_id"]
return fetch_data(user_id)
HttpOnly + Secure + SameSite cookies prevent XSS and CSRF token theft. Instant revocation by deleting the Redis key.
When JWT goes wrong
Long-lived JWTs without refresh. A 30-day JWT can't be revoked. User changes password, admin disables account — token still works for weeks.
Sensitive data in payload. JWT payloads are base64, not encrypted. Never put PII, permissions that change frequently, or secrets in claims.
No algorithm verification. Always specify allowed algorithms in verification. The alg: none attack is still relevant.
jwt.decode(token, key, algorithms=["RS256"]) # explicit algorithm
Token in localStorage. XSS steals it. For SPAs, use HttpOnly cookies for refresh tokens and in-memory access tokens.
When sessions go wrong
Session store as SPOF. If Redis goes down, nobody can authenticate. Replicate and monitor the session store.
Session fixation. Regenerate session ID on login/privilege change.
Not scaling session store. Sticky sessions without shared store break with load balancers. Always use a centralized store (Redis, Memcached).
Hybrid approach
Many production systems combine both:
- Access: short-lived JWT (15 min) for stateless API verification
- Refresh: opaque token in HttpOnly cookie, stored server-side for revocation
- Critical actions: check a server-side blocklist or session flag regardless of JWT validity
This gives you stateless API verification with revocable refresh and the ability to force-logout users.
Rotate JWT signing keys with overlap period — instant key swap invalidates all outstanding tokens and logs out every user.
Token validation checklist
Production JWT verification must include:
payload = jwt.decode(
token,
key=get_signing_key(kid=header["kid"]),
algorithms=["RS256"], # never accept "none"
audience="https://api.example.com",
issuer="https://auth.example.com",
options={"require": ["exp", "iat", "sub"]},
)
- Verify
exp,nbf,iatwith clock skew tolerance (60s) - Check
audmatches your API identifier - Validate
issagainst allowlist - Use JWKS endpoint with key rotation support
M2M vs user auth
| Context | Recommendation |
|---|---|
| SPA user sessions | Hybrid JWT + refresh cookie |
| Mobile apps | PKCE + refresh token in secure storage |
| Service-to-service | Client credentials or mTLS |
| Public API | API keys with scoped permissions |
Don't use session cookies for mobile APIs — WebView cookie handling is inconsistent across platforms.
Pair with OAuth2 client credentials M2M for backend service authentication patterns.
Common production mistakes
Teams get authentication jwt vs sessions wrong in predictable ways:
- Skipping failure-mode rehearsal — run a game day or fault injection exercise before peak traffic, not after the first outage.
- Missing correlation context — every error path should carry request, trace, or tenant identifiers so incidents are debuggable.
- Optimizing for demo, not steady state — load tests, cache warm-up, and cold-start paths matter more than local dev latency.
- Undocumented trade-offs — if you chose speed over strict correctness (or vice versa), write that down for the next engineer.
API design for authentication jwt vs sessions frustrates clients when pagination cursors expire silently, error bodies lack stable machine-readable codes, and rate limits return 429 without Retry-After headers.
Debugging and triage workflow
When authentication jwt vs sessions misbehaves in production, work top-down instead of guessing:
- Confirm scope — one tenant, region, or deployment stage? Narrow blast radius before deep diving.
- Check recent changes — deploys, flag flips, config pushes, and schema migrations in the last 24 hours.
- Compare golden signals — latency, error rate, saturation, and traffic for the affected surface vs. baseline.
- Reproduce minimally — smallest input or scenario that triggers the failure; capture traces/logs with correlation IDs.
- Fix forward or rollback — if rollback is faster than root-cause during incident, rollback first, postmortem second.
- Add a guard — alert, integration test, or circuit breaker so the same class of failure is caught earlier next time.
Document the timeline during triage. Future you (and on-call) will need timestamps, not just conclusions.
Resources
- RFC 7519 — JSON Web Token
- OAuth 2.0 Bearer Token Usage (RFC 6750)
- OWASP JWT Security Cheat Sheet
- OAuth PKCE for mobile apps
- OWASP API Security Top 10
Frequently asked questions
When should I use JWT instead of sessions for API authentication?
Use JWT when you need stateless authentication across multiple services (microservices, serverless) where checking a central session store on every request is impractical. Use sessions when you have a monolith or small service count, need instant revocation, or want simpler security semantics.
What are the main security risks of JWT?
JWTs can't be revoked until they expire — a stolen token is valid until expiry. Mitigate with short-lived access tokens (15 minutes), refresh token rotation, and token blocklists for critical revocations. Never store sensitive data in JWT payloads — they're base64-encoded, not encrypted.
How do refresh tokens work with JWT?
Issue a short-lived access token (JWT, 15 min) and a long-lived refresh token (opaque, stored server-side or in HttpOnly cookie). When the access token expires, the client exchanges the refresh token for a new access token. Rotate refresh tokens on each use to detect theft.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →