Community Article

JWT vs Sessions and When Stateless Bites Back

Sessions are the right default for first-party web apps. JWTs make sense at federation boundaries. Stateless is a property of where state lives, not whether it exists.

JWT vs Sessions and When Stateless Bites Back

Sessions are the right default for first-party web apps. JWTs make sense at federation boundaries. Stateless is a property of where state lives, not whether it exists.

jwt
authentication
security
session-affinity
backend
ninarossi

By @ninarossi

February 2, 2026

·

Updated May 18, 2026

198 views

3

4.1 (10)

"We're switching to JWT because it's stateless." This sentence has launched a thousand auth migrations, and roughly half of them had no business doing it. JWTs are not a session replacement. They are not magically more secure. They are not faster in any meaningful sense. They are a different shape with different trade-offs, and most of the teams I have watched migrate did so for reasons that did not survive contact with their actual workload.

I have run sessions in production. I have run JWTs in production. I have run a hybrid (which is what you usually end up at if you do this thoughtfully). My stance: server-side sessions are the right default for first-party web apps. JWTs make sense when you have a real federation problem (microservices that should not all share a session store, third-party API consumers, mobile clients across multiple backends). Choosing JWT for a single monolithic web app because "stateless is cooler" is a self-inflicted wound that will manifest at the worst possible moment.

What "stateless" actually buys you, and what it costs

Server-side sessions work like this. The user logs in, the server creates a record (session_id -> user_id, csrf_token, expiry, ...) in Redis or a database, and sets a cookie containing the session id. Every subsequent request, the server looks up the session id and reads the user. To revoke a session, delete the row. To force a global logout, truncate the table. To rotate a permission, update the row.

JWTs work like this. The user logs in, the server signs a token containing the user id and any other claims ({ sub: 'u-9', role: 'admin', exp: 1735689600 }), and hands it back. Every request, the client presents the token; the server verifies the signature with a public key (or HMAC secret) and trusts the contents. The server stores nothing.

The "stateless" win is that any server with the public key can verify the token. No round trip to a session store. This sounds great in a slide deck. In practice, three things matter:

  1. Most session stores are not the bottleneck. Redis at 50,000 ops/sec lookups is not your scaling problem. The auth lookup is cheap, hot, and trivially cacheable. If the session store is your bottleneck, you have a different conversation to have.
  2. The signature verification is not free. RS256 verification is more expensive than a Redis hit on the same machine, often by an order of magnitude. HS256 is cheaper but requires every server to share the secret, which defeats the federation argument.
  3. You give up real revocation. A JWT is valid until it expires. Period. You cannot un-issue it. If a user is fired or a token is compromised, the only options are wait for exp, blacklist the token in some shared store (which is a session store with extra steps), or rotate the signing key (which logs out every user).

That last point is the one that bites teams. I have shipped a JWT-only system and then watched a security incident require us to log out every user globally because we had no per-user revocation. Adding a deny-list after the fact is what people mean when they say "JWTs end up needing a session store anyway".

What is actually inside a JWT

JWT = header.payload.signature (each base64url-encoded)

  Header  : { "alg": "RS256", "kid": "key-1", "typ": "JWT" }
  Payload : { "sub": "u-9", "iat": 1735603200, "exp": 1735606800, "aud": "api", "iss": "auth" }
  Signature: RS256(header + "." + payload, private_key)

Two things people miss the first time. First, a JWT is signed, not encrypted. Anyone can decode the payload (paste it into jwt.io) and read the claims. Do not put secrets in a JWT. If you need encryption, use JWE (JSON Web Encryption), which is a separate spec. Most production systems use signed JWTs and put nothing sensitive in the payload.

Second, the signature only proves the token has not been tampered with. It does not prove freshness, it does not prove the user still exists, it does not prove the user's permissions are still what the token says. The token is a snapshot frozen at issue time, valid until exp.

Where the bugs live

The most common JWT bugs I see in code review:

// Wrong: trusts the alg field in the header
const decoded = jwt.verify(token, secret);

// Wrong: trusts an unsigned token
const decoded = jwt.decode(token);

// Wrong: ignores expiry
const decoded = jwt.verify(token, secret, { ignoreExpiration: true });

// Wrong: skips audience and issuer checks
const decoded = jwt.verify(token, secret);  // no audience, no issuer

Pinning the algorithm is critical. The infamous "alg: none" attack works because some libraries default to "trust the alg in the header"; if the attacker sets it to none, the library accepts an unsigned token. A related attack is "alg confusion": a token signed with HS256 using the public RSA key as the HMAC secret, against a server that expects RS256. Always pass algorithms: ['RS256'] (or whichever algorithm you actually issue with) to the verifier.

Skipping aud lets a token issued for a different service be accepted by yours. Skipping iss lets a malicious issuer impersonate your auth server. Skipping exp is just disabling expiry.

Sessions have their own bugs. Cookie misconfiguration is the big one:

Session cookie attributes I set on every login
  Set-Cookie: session=abc123;
              HttpOnly;          # not readable from JavaScript
              Secure;            # only sent over HTTPS
              SameSite=Lax;      # blocks cross-site CSRF on most cases
              Path=/;
              Max-Age=2592000;   # 30 days, refresh on each use

HttpOnly is non-negotiable; without it, an XSS bug becomes a session theft. Secure is non-negotiable on anything not running on localhost. SameSite=Lax is the modern default; Strict if you do not use cross-site embeds; None (which requires Secure) only if you actually need third-party cookie behavior and you have CSRF defenses elsewhere.

CSRF protection is the part sessions need that JWTs (in the Authorization header) get for free. With cookie-based sessions, you need either SameSite=Lax on the cookie (which is now the browser default), a CSRF token tied to the session, or double-submit cookies. Pick one, document it, audit it.

The real comparison table

ConcernServer-side sessionJWT
Server stateYes (one row per session)No
Per-user revocationTrivial (delete row)Hard (deny-list or wait for exp)
Global logoutTrivial (truncate table)Hard (rotate signing key)
Permission updatesReflected immediatelyStale until token expires
Verification costDB/Redis hitSignature verification
Cross-domainHard (cookies are first-party)Easy (Authorization: Bearer)
Mobile clientsWorkable but cookie-awkwardNatural fit
Microservice federationEach service hits the session storeEach service verifies independently
Token size on the wire~50 bytes (cookie)~500-1500 bytes (JWT)
Replay protectionServer-side rotationNone inherent (use jti or short exp)
XSS impactSession id can be stolen if HttpOnly is missingToken can be stolen if storage is reachable from JS
CSRF impactReal, mitigated by SameSite + tokensNone if Authorization header (not cookie)

When stateless actually pays off

Three real cases I have seen where switching to JWT was the right call.

The first was a microservices estate where every service was hitting a central session-store cluster. The traffic pattern made the session store the hottest piece of infrastructure in the system, and the operational cost of running it at that scale was real. Moving to short-lived JWTs (5-minute expiry, no revocation needed at that horizon, refresh-token rotation handled the long tail) cut session-store traffic by 95% and removed a tier of infrastructure entirely. They kept a tiny session store for the refresh tokens, which was the correct hybrid.

The second was a public API consumed by third-party integrators across many backends. Sessions in cookies do not cross to other people's domains cleanly; bearer tokens in the Authorization header do. The auth protocol they were already using (OAuth 2.0 with OIDC) hands you an access token; using JWT for it was the natural shape.

The third was a mobile app where every request goes through Authorization headers anyway. Cookies in mobile webviews are awkward, and the network model is different from a browser session. JWT-shaped tokens fit the constraint better.

Common across all three: the federation or boundary-crossing argument is real, the lack of per-user revocation is acceptable because tokens are short-lived, and there is a plan for the long tail (refresh rotation or deny-list).

My default architecture

For a first-party web app with one or two backends, I default to:

Auth architecture for first-party web app
  Login  -> backend creates session row in Redis, sets cookie
  Cookie : HttpOnly + Secure + SameSite=Lax + Path=/
  Every request -> backend reads session from Redis
  Logout -> backend deletes session row, clears cookie
  Session TTL: 30 days, refreshed on use; absolute max 90 days

Why this and not JWTs? Because revocation is trivial, permission updates are reflected immediately, the operational cost is one Redis cluster I was going to need anyway, and CSRF is handled by the cookie attributes plus a token on state-changing requests.

For a microservices estate, mobile app, or public API, I default to:

Auth architecture for microservices / mobile / public API
  Login  -> auth server issues short-lived access JWT (5-15 min) + long-lived refresh token
  Refresh token: stored server-side OR in mobile keychain, NEVER localStorage
  Every API request -> presents access JWT, verified locally by service
  When access JWT expires -> client uses refresh token to get a new one
  Refresh token rotation on every use (OAuth 2.1 default)
  Optional: deny-list for compromised tokens, scoped per-user

The hybrid is where most production systems land. Stateless tokens for the hot path, server-side state for refresh and revocation. The rule of thumb: short access tokens make most of the JWT problems disappear (a 5-minute window for stale claims is acceptable; a 30-day window is not).

The "stateless" myth and the hybrid reality

If I could get one idea across about JWT vs sessions it is this: "stateless" is a property of where you keep the state, not whether state exists. JWT systems still have state. The state is the user row in the database, the JWKS keys at the IdP, and (almost always) the refresh token store. The thing that is actually stateless is the access token validation step on the API server, and that step is one of the cheapest in the request lifecycle anyway.

Optimizing for a property your users never observe is a classic engineering mistake. What users observe is: do I get logged out unexpectedly, do my permission changes take effect, do I trust the security model to revoke a stolen token. Sessions answer those questions cleanly. JWTs answer them awkwardly unless you bolt on the very thing JWTs were supposed to remove. So pick by the boundary problem, not by the buzzword. Sessions for first-party web apps. JWTs for federation, mobile, and public APIs. A hybrid for everything in between, which is most things. That is the architecture that has held up for me across every system I have shipped with auth in it.

Back to Articles