← Back to blog
Security·July 22, 2026·7 min read

JWT vs Session Cookies: The Trade Isn't Statelessness, It's Revocation

"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.

Two different answers to the same question

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.

What's actually inside a JWT

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.

The trade-off, honestly

DimensionSession CookieJWT
Where identity livesServer-side storeInside the token
Validation costOne store lookup per requestSignature verify (CPU only)
Revoke a single sessionDelete one row — instantNot directly possible
Horizontal scalingNeeds shared/sticky storeNo shared state needed
Token size on the wire~20–50 bytes~400–1000+ bytes
Reads user data without DBNoYes (claims are embedded)
Cross-service / cross-domainAwkwardNatural fit
Blast radius if leakedUntil you delete itUntil 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.

The revocation problem, which is the real story

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:

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."

Session cookie opaque id store lookup who + valid? revoke = delete row instant, per-session JWT signed claims verify signature (no lookup) revoke = wait for exp or rebuild server state

Where do you even store the thing?

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:

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).

So when is each actually right?

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.

Takeaway

"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.

#jwt#session-cookies#authentication#web-security#token-revocation#auth

Related reading

Security
bcrypt vs scrypt vs Argon2: How Password Hashing Actually Differs