JWTs vs Sessions: The Debate, Settled-ish
Why revocation is where stateless goes to die, the XSS blast-radius problem, the specific habitats where JWTs genuinely shine, and the sessions-at-the-edge / tokens-between-machines architecture.
Somewhere around 2015, the industry collectively decided sessions were legacy and JWTs were the future, and a decade of security incidents has been quietly walking that back ever since. The honest version: server-side sessions remain the right default for almost every web application, and JWTs are a specialized tool for federation and service-to-service trust — brilliant at their actual job, hazardous when hired for the wrong one. Let me make that case with mechanisms, not vibes.
What each thing actually is
Session: server stores state (user ID, roles, metadata) keyed by a random opaque token in an httpOnly cookie. The token means nothing; all truth lives server-side, one lookup away — in Redis or the database, both effortless at any scale short of planetary.
JWT: a signed (not encrypted — base64 is not privacy!) package of claims the client holds. The server validates the signature and trusts the contents without any lookup. That statelessness is the entire feature — and the entire problem, because a thing you can't look up is a thing you can't take back.
Revocation: where stateless goes to die
The question that decides most arguments: the user clicks "log out of all devices" — or you disable a compromised account — what happens? Sessions: delete the rows; done, globally, instantly. JWTs: every already-issued token remains valid until expiry, because validity lives in mathematics, not in your database. The industry's workarounds form a comedy in three acts: short expiries (now you need refresh-token machinery — which is server-side state), token blocklists (checked on every request — congratulations, you've rebuilt session lookups with extra steps), and "just rotate the signing key" (mass-logout of every user; the nuclear option). If your system needs the ability to end a login right now — and account takeover response means it does — you need server-side state somewhere. At which point the "stateless" premium you paid bought nothing.
The XSS blast-radius problem
Where does the client keep the token? Sessions ride in httpOnly, Secure, SameSite cookies — invisible to JavaScript, so an XSS foothold can't exfiltrate them (CSRF is the cookie trade-off, and SameSite plus framework CSRF tokens — Laravel ships this wired — handle it). JWTs in practice land in localStorage or JS-readable memory, where any XSS reads them and walks away with a bearer credential valid until expiry, usable from anywhere. You can put JWTs in httpOnly cookies too — at which point, again, you've rebuilt cookie-based auth with a fatter token and no revocation. The pattern repeats: every hardening of JWT-for-web converges back toward sessions.
Where JWTs genuinely shine
None of the above means JWTs are bad — it means their habitat is specific:
- Federation between parties. The ID token in OIDC — identity asserted across trust boundaries where a shared session store can't exist. This is what JWTs were for.
- Service-to-service and API gateways. Short-lived (minutes) access tokens carrying claims between internal services — the gateway validates once, downstream services trust the signature, nobody shares a session store. Short expiry makes revocation moot; statelessness pays real rent here.
- Delegated, scoped, expiring capability — signed URLs, webhook auth, "this mobile client may call these three endpoints for 10 minutes" — anywhere the token is the credential and its short life is the security model.
The architecture that follows: sessions at the human edge, JWTs between machines. Your Laravel/. NET web app authenticates browsers with cookie sessions; your gateway mints five-minute JWTs for the service mesh behind it. Both tools, both in their habitat.
Implementation notes for whichever you run
Sessions: httpOnly + Secure + SameSite=Lax minimum; regenerate ID on login
(fixation) and privilege change; Redis store with absolute + idle
timeouts; "active sessions" UI with per-device revoke = user trust.
JWTs: alg allowlist server-side (the alg:none and RS256→HS256 confusion
attacks live forever); validate iss/aud/exp ALWAYS; asymmetric keys
(RS256/EdDSA) so verifiers can't mint; kid + JWKS for rotation;
expiry ≤ 15 min for access tokens; refresh tokens are server-side
state — rotate them on use and detect reuse (token theft alarm).
Both: the token is not the authorization. Roles/permissions checked at
use-time from source of truth — a stale "admin" claim in a valid
token is exactly the incident you think it is.
The meta-lesson outlives the debate: statelessness is a trade, not a virtue. You exchange lookup costs (tiny, cacheable) for control costs (revocation, rotation, blast radius). At the human login edge, control wins — it always did. The pendulum's return to boring sessions isn't regression; it's the industry finishing the experiment and writing down the result.
Auth architecture reviews — session hardening, token flows, the whole OAuth stack — are standard fare in my consulting, ideally before the pentest report makes the argument for me.