Eqii
Security 9 min read

JWT vs Session Cookies: Authentication Compared

Stateless JWTs vs stateful session cookies — storage, revocation, size, CSRF vs XSS tradeoffs, and when each approach wins. A security-focused comparison.

Why this comparison still matters

Authentication is the front door of your application, and the choice between JSON Web Tokens (JWT) and session cookies shapes your security model for years. The debate is sometimes framed as 'stateless vs stateful,' but the real trade-offs are more subtle: where tokens live, how you revoke them, what attack surface they create, and how your architecture scales. Get this wrong and you ship a vulnerable auth system; get it right and you stop thinking about it for years.

This article compares the two approaches on architecture, storage, revocation, size, and the CSRF vs XSS tradeoff.

JWT: stateless, self-contained tokens

JWT is specified in RFC 7519, built on JWS (RFC 7515) for signing and JWE (RFC 7516) for encryption. A JWT has three base64url-encoded parts separated by dots: header, payload, signature. The payload contains claims — typically user ID, roles, issuer, expiry (exp), and issued-at (iat). The signature is computed by the server using a secret (HMAC-SHA256) or a private key (RS256, ES256), so any tampering with the payload invalidates the signature.

The appeal is statelessness. The server does not need to look up a session in a database — it verifies the signature, checks exp, and trusts the claims. That simplifies horizontal scaling: any server can authenticate any request without a shared session store. JWTs are commonly used in OAuth 2.0 and OpenID Connect as access tokens (often opaque tokens in practice, but JWTs where the resource server benefits from local validation).

The downsides are well-known and serious. Revocation is hard: once a JWT is signed, it is valid until exp. There is no built-in way to invalidate a stolen token before it expires. Workarounds — short exp times (5–15 minutes) plus refresh tokens, server-side blacklists of revoked token IDs, or versioned signing keys — all reintroduce the very state JWT was supposed to eliminate. A blacklist checked on every request is functionally a session store.

Storage on the client is the other problem. Storing JWTs in localStorage exposes them to any JavaScript running on the page, including XSS injected by an attacker. Storing them in httpOnly cookies protects against XSS but reintroduces CSRF, requires the token to be sent on every request (including cross-site), and negates one of JWT's selling points — that you can use it across domains easily. Most security guidance now recommends keeping access tokens short-lived and refresh tokens in httpOnly cookies.

JWT size is also a consideration. A typical signed JWT is 500–1000 bytes versus ~40 bytes for an opaque session ID, which adds up on high-traffic APIs.

Session cookies: stateful, server-controlled

Session-based auth is the classic pattern: the server creates a session record (in memory, Redis, or a database) with a random, high-entropy session ID, and sets that ID as an httpOnly, Secure, SameSite cookie on the response. On each subsequent request, the browser sends the cookie automatically, and the server looks up the session by ID.

The headline advantages are revocation and security. Revoking a session is a single database delete — the next request finds no session and is rejected. This is critical for 'log out everywhere,' forced password resets, and incident response. Cookies marked httpOnly cannot be read by JavaScript, eliminating the XSS-token-theft vector entirely. SameSite=Lax (the modern default) and SameSite=Strict mitigate CSRF by preventing the cookie from being sent on cross-site requests.

CSRF is the traditional counter-argument, and historically it was real: a malicious site could trigger a state-changing POST that the browser would send with cookies attached. Modern defenses — SameSite cookies, anti-CSRF tokens (synchronizer pattern or double-submit), and requiring custom headers on state-changing requests — have largely solved this. The OWASP guidance now treats SameSite=Lax as sufficient for most applications.

The cost of sessions is the server-side store. Every request needs a lookup — fast in Redis (sub-millisecond) but still a network round-trip. Horizontal scaling requires a shared session store, which is a single point of failure if not replicated. Session fixation (forcing a known session ID on a victim) must be defended against by rotating the session ID on login and privilege changes.

Cookies also have constraints that matter for some architectures. They are sent only to the origin domain (or subdomains if configured), which complicates cross-domain SSO. They are sent on every request to that origin, which adds bytes to static asset requests unless you scope them tightly. Mobile apps and CLI tools that don't run in a browser must manage tokens manually, where the cookie abstraction doesn't help.

JWT attack surface and hardening

JWT has a distinctive attack surface that session cookies do not. The 'alg: none' attack exploits implementations that trust the algorithm field in the header — an attacker sets alg to 'none' and removes the signature, and a vulnerable server accepts the unsigned token as valid. The defense is to whitelist expected algorithms explicitly when verifying and to reject 'none' regardless of what the header claims. The 'alg confusion' attack tricks servers configured to accept both HS256 and RS256 into using the RSA public key as an HMAC secret, forging valid tokens. The defense is again to enforce a single algorithm per key.

Refresh token rotation is the modern best practice for JWT-based auth. The client stores a refresh token (long-lived, in an httpOnly cookie); it exchanges the refresh token for a short-lived access token (5–15 minutes) via a /token endpoint. The refresh endpoint rotates the refresh token on each use and revokes the previous one — if a stolen refresh token is replayed, the legitimate client's next refresh fails, detecting the theft. This pattern, recommended by OAuth 2.0 for Browser-Based Apps (RFC 9926, formerly the BCP), gives you near-session levels of revocation with stateless API validation.

A specific recommendation on access token lifetimes: 5–15 minutes for access tokens, 7–30 days for refresh tokens rotated on each use, and a hard session cap (force re-login) of 30–90 days depending on sensitivity. Never put sensitive data (PII, secrets) in the JWT payload — it is base64-encoded, not encrypted, and anyone who intercepts the token can read it. Use JWE (RFC 7516) if you genuinely need encrypted tokens, but ask first whether the data really needs to be in the token.

Side-by-side comparison

DimensionJWTSession cookies
SpecRFC 7519 (JWS 7515, JWE 7516)No spec; standard practice
StateStateless (server verifies signature)Stateful (server stores session)
RevocationHard (needs blacklist or short exp)Trivial (delete session)
Typical size500–1000+ bytes~40 bytes (session ID)
Storage on clientlocalStorage, sessionStorage, or cookiehttpOnly cookie automatically
XSS riskHigh if in localStorage; lower in httpOnly cookieLow (httpOnly cookies)
CSRF riskLower (token sent via Authorization header)Higher without SameSite/CSRF token
Cross-domainEasy (just send token)Harder (cookie scoping)
ScalingEasy (no shared session store)Needs shared store (Redis)
Logout everywhereHardTrivial
Mobile/CLI clientsNaturalAwkward (no browser)

When to choose which

Choose session cookies when you are building a traditional web application where users log in via a browser and the backend is a single service or a small set of services sharing a session store. This is the default for SaaS apps, admin panels, e-commerce, and most B2C products. The combination of httpOnly + Secure + SameSite=Lax cookies, a Redis-backed session store, and a CSRF token on state-changing requests gives you a battle-tested security posture with trivial revocation.

Choose JWT when you are building a stateless API consumed by mobile apps, SPAs with a separate backend, or microservices where you want each service to validate tokens locally without a shared session lookup. Use short-lived access tokens (5–15 minutes) for API calls and longer-lived refresh tokens stored in httpOnly cookies. Sign with RS256 or ES256 so resource services can verify with the public key without sharing the signing secret. Implement a revocation list (jti claim) for emergencies even if you rarely use it — when a token is stolen, you need a way to kill it.

Avoid JWT in localStorage for any non-trivial application. The XSS exposure is real and the convenience is marginal. Avoid long-lived JWTs (days or weeks) — if you can't revoke them, you can't respond to a breach. Avoid opaque tokens labeled as JWTs (where the server still looks them up in a database) — that combines the downsides of both approaches.

Avoid session cookies when your client is a mobile app or CLI tool that cannot manage cookies, or when you have a genuine architectural need for stateless validation (e.g., a high-traffic API where the session lookup would be the bottleneck). In those cases, JWT with refresh tokens is the right pattern.

A hybrid approach works well: issue a refresh token in an httpOnly cookie that exchanges for a short-lived JWT access token, which the SPA then sends in the Authorization header for API calls. This gives you revocation (delete the refresh session), stateless API validation (verify the JWT signature locally), and protection against XSS (the access token is short-lived and the refresh token is httpOnly).

Conclusion

JWT and session cookies are not really competitors — they are different points in the statelessness trade-off. Session cookies prioritize revocation and XSS resistance at the cost of a server-side store and CSRF defenses. JWTs prioritize stateless validation and cross-domain flexibility at the cost of revocation and careful storage. For browser-based web apps, default to session cookies with modern CSRF defenses. For stateless APIs and non-browser clients, use short-lived JWT access tokens plus refresh tokens in httpOnly cookies. The 'JWT in localStorage' pattern that became popular around 2017 is a security smell — avoid it.