You Probably Don't Need JWT: Server-Side Sessions Are Still the Right Default

Somewhere around 2015, JWT stopped being a tool and became a reflex. A developer needs to know who is making a request, reaches for a library, and the library hands back a signed token rather than a session cookie. From there it propagates: the next service copies the first, the tutorial copies the service, and within a few years "use JWTs for auth" is simply what one does, in the way one uses Postgres or puts a load balancer in front of things. Nobody decided this. It accreted.
The pitch that did the accreting was statelessness. You don't need a session store, the argument runs, because the token carries its own claims and its own signature; any service can verify it in isolation, no shared database, no round trip, infinitely horizontally scalable. It is a genuinely good pitch. It is also, for the overwhelming majority of web applications, a solution to a problem they do not have, sold at a price they do not notice they are paying.
The boring default — an opaque session ID in a cookie, backed by a server-side store — was right all along. It is right for most of the people who replaced it. This piece is about why, where the exceptions genuinely live, and how to run the boring thing well so you are not tempted away from it.
The Statelessness Myth
Start with the claim doing the heavy lifting, because if it falls, most of the rest goes with it. "Stateless equals scalable" treats the session lookup as the expensive thing you are heroically avoiding. In practice, the session lookup is one GET against Redis, returning a few hundred bytes from memory in well under a millisecond, on infrastructure you almost certainly already run for caching and rate limiting. That is not a bottleneck. It has never once been the reason an application failed to scale.
What the stateless token actually buys you is the removal of that one fast, cheap, centralised lookup — and in exchange it scatters your authority across every token you have ever issued, none of which you can see, recall, or change. You have not eliminated state. You have distributed it to your users' browsers and given up the ability to manage it. That is a strange trade to make by default, and a stranger one to make for an app that runs on three boxes behind a load balancer and will never run on three hundred.
The honest version of the scalability argument applies to a specific shape of system: many independent services, no shared data plane, requests fanning out across trust boundaries where a central session check would mean a network hop into someone else's domain. If that is your architecture, the stateless token earns its keep. If your architecture is a monolith and two workers talking to the same Redis, you are paying the full cost of statelessness for none of the benefit.
The Revocation Problem Nobody Mentions
Here is the part that tends to be conspicuously absent from the tutorials. You cannot easily revoke a JWT. The whole point of the design is that any service can validate the token without asking anyone — which means there is no one to ask to say no.
A user logs out. With a session, you delete the row and the credential is dead the instant the next request arrives. With a JWT, "logging out" deletes the cookie from one browser and leaves the token itself perfectly valid until it expires; anyone holding a copy is still authenticated. An employee is dismissed, a token leaks into a log aggregator, a phone is stolen — in each case the question is the same. How do I make this credential stop working right now? For a session, the answer is one DEL. For a JWT, the honest answer is that you can't, not really.
What people do instead is rebuild the thing they removed. They add a revocation list — a server-side set of token IDs that have been invalidated, checked on every request. Which is a session store, wearing a false moustache, except now you also carry the signature verification, the larger payload, and the conceptual overhead of pretending to be stateless while consulting central state on every call. The other workaround is to make tokens very short-lived and lean on refresh tokens to paper over the gap, which narrows the revocation window without closing it and introduces a second credential, a refresh endpoint, and rotation logic to get wrong. You have reinvented sessions, badly, and called it modern.
Size, Footguns, and the Bill You Don't See
Two more costs, briefly, because they compound.
A session ID is a small opaque string — call it 32 bytes. A JWT carrying a user ID, a handful of roles, an issuer, an audience, and an expiry, base64-encoded with a signature, runs comfortably past 800 bytes and often well into the kilobytes once someone discovers you can stuff claims into it. That token rides on every single request, in a header, for the life of the session. On a chatty SPA making hundreds of calls, you are shipping a small book's worth of redundant signed claims up the wire to re-prove something a cookie proved in 32 bytes.
Then there are the footguns, which are specific and have drawn real blood. The alg header is attacker-controlled in a naive implementation, and alg: "none" famously tells some libraries to skip signature verification entirely — a forged token sails straight through. The RS256-to-HS256 key-confusion attack tricks a server into verifying an asymmetric token using its public key as an HMAC secret, which is, of course, public. These are not exotic; they are recurring CVEs, and they exist because JWT is a flexible cryptographic construction kit handed to application developers who wanted a login cookie. An opaque session ID has no algorithm to confuse, no claims to forge, and no signature to skip. It is a random number that means nothing without the server. There is very little to get wrong, which is the highest praise you can give a security primitive.
Here is the comparison in one place.
| Property | JWT session | Opaque session ID |
|---|---|---|
| Revocation | Hard — needs a denylist (i.e. a session store anyway) | Trivial — DEL the key |
| Size on the wire | ~0.8–4 KB per request | ~32 bytes per request |
| Rotation / key churn | Re-key signing material, juggle refresh tokens | Rotate the secret behind the store; sessions intact |
| Validation cost | Signature verify per request, per service | One in-memory GET |
| Failure surface | alg:none, key confusion, clock skew, claim bloat |
Random string; almost nothing to misimplement |
| Server-side state | Pretends to none; needs it for revocation | Honest about it; one Redis you already run |
Where JWT Genuinely Fits
None of this means JWT is bad cryptography or that you should rip it out wherever it appears. It means it is the wrong default for browser sessions, which is a different claim. There are places it is exactly right.
Short-lived service-to-service tokens are the clearest case: machine A needs to prove identity to machine B across a trust boundary, the token lives for sixty seconds, and there is no human to log out and no revocation expectation to violate. The expiry is the revocation. OIDC ID tokens are another — a JWT is the format, it is consumed once at login to establish a session, and then you put it down. That is the pattern, incidentally: use the token to start a session, then hold the session, rather than treating the token as the session for the next eight hours. Genuinely cross-domain or federated scenarios, where a central session check would mean reaching into another organisation's infrastructure, are the third. In all three, statelessness is buying something real, and short lifetimes make the revocation problem moot.
The thread connecting them is that the token is short-lived, scoped, and ideally not handled by a browser as a long-running credential. The moment your JWT is a multi-hour login cookie for an end user, you are in the wrong quadrant.
Running Sessions Well
The boring default is only the right default if you run it competently, so here is the short version. Generate session IDs from a cryptographically secure random source with enough entropy to be unguessable — 128 bits or more, not your framework's auto-increment id with a bit of base64 over it. Store them server-side in Redis or your database with a sensible TTL and idle timeout. Regenerate the ID on privilege changes, login especially, to close off session fixation. And get the cookie flags right, because this is where sessions are actually lost.
Set-Cookie: session=9f8b...e21a; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400
HttpOnly keeps JavaScript away from the cookie, which neutralises the most common XSS-to-token-theft path. Secure refuses to send it over plain HTTP. SameSite=Lax is a solid default against CSRF for most apps; Strict if you can tolerate the navigation quirks. Scope Path and set a Max-Age that matches your security posture rather than your convenience. Get these five right and you have closed the attack surface that people imagine JWTs somehow protect them from — they don't; a JWT in a non-HttpOnly cookie or in localStorage is more exposed to XSS, not less.
Pick the boring default. Reach for JWT when you have an actual cross-boundary, short-lived, stateless problem — and notice how rarely that describes the login form in front of you. For everything else, it is a random string and a Redis GET, and it has been quietly correct the entire time.