"JWTs are stateless, so they scale better" is the sentence that leads teams straight into the logout-doesn't-work bug. Here's the real trade-off, the revocation problem it creates, and when each auth model is actually the right call.
Somewhere between "we need auth" and shipping, a decision gets made that quietly shapes the next two years of a codebase: JWTs or session cookies. It's usually made on a single sentence — "JWTs are stateless, so they scale better" — and that sentence is true, technically, and misleading, practically. The team that picks JWTs on that basis is often the same team that, six months later, files a bug titled "logout doesn't actually log the user out."
That bug is not a mistake in their implementation. It is the direct, designed-in consequence of the property they chose the token for. Understanding why is the whole point of this comparison — because "stateless" is not a free win, it's a trade, and the trade is revocation.
Every authenticated request has to answer one question: who is this, and are they still allowed in? Session cookies and JWTs answer it in opposite ways.
A session cookie carries an opaque, meaningless identifier — a random string like s%3AaGVsbG8.... The server looks that identifier up in a store (Redis, Postgres, Memcached) to find who it belongs to and whether it's still valid. The cookie is a claim check; the server holds the actual coat.
A JWT (JSON Web Token) carries the coat itself. The identity — user ID, roles, expiry — is inside the token, and the server trusts it because the token is cryptographically signed. No lookup required. The server verifies the signature, reads the claims, and proceeds. That's the "stateless" part: the server keeps no per-session record.
Everything else — the storage debates, the revocation pain, the XSS-vs-CSRF arguments — falls out of this one structural difference.
A JWT is three Base64url-encoded parts joined by dots: header.payload.signature. It is not encrypted — anyone holding it can read the payload. Decode this real-shaped token and you get:
// Header
{ "alg": "HS256", "typ": "JWT" }
// Payload (the "claims")
{
"sub": "user_8842",
"role": "editor",
"iat": 1753180800,
"exp": 1753184400
}
// Signature
HMACSHA256(
base64url(header) + "." + base64url(payload),
server_secret
)
The signature is the load-bearing part. It doesn't hide the payload — it proves the payload wasn't tampered with. Flip "role": "editor" to "role": "admin" and the signature no longer matches, because whoever forged it doesn't have server_secret. The server recomputes the HMAC on every request and rejects any mismatch.
This is why you should never put secrets in a JWT payload and never trust an unverified one. It's also why a quick decode is a routine debugging move — pasting a token into a JWT decoder to read the exp claim or confirm which role got baked in is faster than adding a log line. Just remember decoding and verifying are different acts: reading the claims takes no secret; trusting them takes the signature check.
One footgun worth naming: the alg field is attacker-influenced data. The classic vulnerability is alg: none (a token with an empty signature that some naive libraries accepted as valid) and the RS256→HS256 confusion attack. Always pin the expected algorithm server-side; never let the token tell you how to verify itself.
| Dimension | Session Cookie | JWT |
|---|---|---|
| Where identity lives | Server-side store | Inside the token |
| Validation cost | One store lookup per request | Signature verify (CPU only) |
| Revoke a single session | Delete one row — instant | Not directly possible |
| Horizontal scaling | Needs shared/sticky store | No shared state needed |
| Token size on the wire | ~20–50 bytes | ~400–1000+ bytes |
| Reads user data without DB | No | Yes (claims are embedded) |
| Cross-service / cross-domain | Awkward | Natural fit |
| Blast radius if leaked | Until you delete it | Until it expires |
Read that table and the picture inverts the marketing. The JWT's headline advantage — no lookup — is the same property that produces its worst weakness: you cannot un-issue it.
With sessions, logout is trivial: delete the row. Ban a user, force-logout after a password change, kill a stolen session — all one delete, effective on the very next request.
With a JWT, there is nothing to delete. The token is valid because its signature is valid and its exp hasn't passed. The server, by design, isn't keeping a record to invalidate. So "log this specific token out right now" has no clean answer. The standard mitigations each give something back:
jti against a store of killed tokens. This works — but you've now reintroduced the per-request lookup you adopted JWTs to avoid. You've built a session store with extra steps.None of these is wrong. But notice the pattern: every serious JWT auth system, at scale, ends up bolting server-side state back on. The "stateless" purity rarely survives contact with production requirements like "let admins revoke access."
This debate is often conflated with the JWT-vs-session choice, but it's orthogonal — any token, opaque or signed, has to live somewhere in the browser, and the two options have opposite failure modes:
localStorage. Convenient, works cross-domain, survives page loads. But it's readable by any JavaScript on the page, which means a single XSS bug hands the attacker the token. There's no HttpOnly protection because it's not a cookie.HttpOnly cookie. JavaScript can't read it, which neutralizes token theft via XSS. But cookies are sent automatically by the browser, which opens the door to CSRF — mitigated with the SameSite attribute (Lax or Strict) and, for sensitive flows, anti-CSRF tokens.The security consensus has hardened here: prefer HttpOnly; Secure; SameSite cookies over localStorage for anything that authenticates. XSS is more common than CSRF in modern SPA codebases, and an HttpOnly cookie takes token exfiltration off the table entirely. Note this means you can absolutely put a JWT in a cookie — "JWT vs cookie" is a false dichotomy; the real axes are what's in the token (opaque vs signed claims) and where it's stored (JS-readable vs HttpOnly).
Reach for session cookies when it's a classic first-party web app with a browser front-end and a backend you control. Instant revocation, small tokens, and a dead-simple mental model matter more than shaving a Redis lookup. This covers the large majority of products, and "boring session cookies in an HttpOnly cookie" is a genuinely good default that too many teams talk themselves out of.
Reach for JWTs when the embedded-claims property earns its keep: stateless service-to-service auth in a microservice mesh, short-lived tokens minted by an identity provider (OAuth/OIDC access tokens are JWTs for exactly this reason), or a case where the verifying service genuinely cannot call back to a central store on every request. In those settings the signature-verify-no-lookup model is the point, not a liability — and the revocation weakness is managed with short expiries because the tokens were never meant to be long-lived.
The failure mode to avoid is picking JWTs for a plain first-party web app because they sound modern, then spending a quarter rebuilding a session store out of denylists to get revocation back.
"Stateless" is not a scorecard win — it's a description of a trade. A JWT moves identity out of the server and into the token, and that single move buys you no-lookup verification and costs you clean revocation; you can have one or the other, and every workaround for the revocation cost quietly adds the state back. Choose sessions when you own the whole app and want revocation for free. Choose JWTs when something on the wire genuinely can't phone home to validate — and when you do, keep them short-lived, pin the algorithm, and store them in an HttpOnly cookie, not localStorage.