JWT Decoder ยท 6 min read
JWT vs Sessions: When Stateless Tokens Are the Wrong Choice
JWTs solved a problem most apps don't have. For a single-server web app with a database, server-side sessions are simpler, smaller, and more secure. Here is when each is actually right.
Somewhere around 2017, JWTs became the default authentication mechanism for new web apps. The reasoning was usually some combination of "they're stateless" and "they scale." A lot of those apps didn't need either property and ended up with a worse system than the one they replaced. Here is when JWTs are genuinely the right call โ and when a boring server-side session cookie is the better answer.
What a Session Cookie Actually Does
A traditional session works like this. The user logs in. The server generates a random opaque ID, stores it in a database keyed to the user's ID and any session metadata, and sets it as an HTTP-only cookie. On subsequent requests, the server looks up the session ID, finds the user, and proceeds.
That's it. The cookie carries no information beyond the random ID. All authority lives on the server. To log a user out, you delete the row. To revoke all sessions for a user, you delete all their rows. To change a user's permissions, you update their record and the next request reflects it.
What a JWT Does Differently
A JWT carries the user's identity (and often permissions) in the token itself, signed by the server. On each request, the server verifies the signature and trusts the payload. There's no database lookup โ the token is self-contained.
That's the "stateless" pitch: any server with the verification key can validate the token, no shared session store required. Useful when you genuinely have multiple independent services that need to authorise the same user.
The Problems with JWT-as-Session
Most apps using JWTs aren't in that situation. They're a single backend with a single database, treating JWTs as drop-in session tokens. That introduces a pile of issues:
- Revocation is hard. A JWT is valid until it expires. If a user logs out, changes their password, or has their account compromised, the JWT in their browser is still cryptographically valid. The standard fix โ a server-side token blocklist โ undoes the "stateless" benefit, because now every request hits the database anyway.
- Stale data. A JWT carries the user's permissions at issue time. If you demote a user from admin, their JWT still claims admin rights until it expires. Sessions don't have this problem because permissions are looked up fresh.
- They're bigger. A session cookie is typically 32-64 bytes. A JWT with a few claims and an RS256 signature is 600-1000 bytes. Multiplied across every request, that's real bandwidth, especially on mobile.
- Storage is awkward. Cookies are easy. JWTs are commonly stored in
localStoragefor SPA convenience, which exposes them to any XSS. Storing them in HTTP-only cookies works but defeats most of the JWT-specific tooling people reach for. - Crypto is more attack surface. The whole class of alg:none, key confusion, and JWS parser bugs only exists because the token has a signature to verify. Opaque session IDs have no algorithm to confuse.
When JWTs Are the Right Choice
JWTs earn their complexity in a few specific situations:
- True microservices with independent verification. Service A authenticates the user, Service B (different team, different database) needs to authorise them. A signed JWT lets B verify A's claim without calling A on every request.
- Federated identity. OpenID Connect uses JWTs (specifically
id_token) to ferry identity claims from an identity provider to a relying party. This is what JWTs are good at and where they earn their RFC. - Short-lived access tokens. The OAuth2 access token pattern: a long-lived refresh token (server-side, revocable) issues short-lived JWTs (5-15 minutes) that don't need revocation because they expire fast enough. This is the "right" use of JWTs in modern auth.
- Signed state for stateless flows. Email verification links, password reset tokens, signup invites. The JWT is single-use, short-lived, and travels through a channel (email) that has no session.
The Common Anti-Pattern
A monolithic Rails or Django or Express app with one database, where the user logs in, gets a JWT, and the server validates it on every request โ that's a session, written badly. You've added crypto attack surface, made revocation harder, made tokens bigger, and gained nothing because you're hitting the database anyway for user data.
If that's the architecture, use sessions. They're a solved problem. Every framework has a battle-tested session middleware. Cookies are correctly handled by every browser. Logout works. Password changes propagate. Admin tools can list and kill active sessions.
The Hybrid That Actually Works
For apps that genuinely need both โ a web app and a public API โ a common pattern is sessions for the web frontend and short-lived JWTs (issued by the same server, scoped to the API) for programmatic access. The web user's session is a cookie; their API tokens are JWTs. Each tool does the job it's good at.
The Decision in One Sentence
Use sessions unless you have a specific reason a stateless, signed token solves a problem you actually have. "Statelessness" without a multi-service or federated context is a feature you don't need, paid for in complexity you do.
References
- Jones, M., Bradley, J., & Sakimura, N. (2015). RFC 7519: JSON Web Token (JWT). Internet Engineering Task Force.
- Sandberg, J. (2020). Stop using JWT for sessions. joepie91 blog.
- OWASP Foundation. (2023). Session Management Cheat Sheet. OWASP.
- Auth0. (2022). Why is OAuth2 using JWTs and how does refresh work? Auth0 Engineering blog.
- Stripe. (2023). API authentication and session management. Stripe API documentation.