JWT vs Session Cookies
JWTs carry user identity in a signed, self-contained token that any server can verify. Session cookies store a random ID that maps to server-side state. The choice determines how you scale authentication, how you handle logout, and how you manage security incidents.
TL;DR
- JWT: stateless, signed token containing user claims. No server-side store needed. Hard to revoke before expiry.
- Session cookies: server stores session state; cookie holds an opaque session ID. Instantly revocable. Requires shared session store for multi-server deployments.
- JWTs win: microservices, cross-domain APIs, mobile apps, horizontal scaling without sticky sessions.
- Session cookies win: traditional web apps on one domain, instant revocation requirements, simpler security model.
At a Glance
| Attribute | JWT | Session Cookie |
|---|---|---|
| State model | Stateless — token is self-contained | Stateful — session stored server-side |
| Storage | localStorage / memory / cookie | Server DB or Redis; cookie holds ID only |
| Revocation | Hard — must wait for expiry or use blocklist | Instant — delete session from store |
| Scaling | Excellent — any server verifies via signature | Requires shared session store (Redis, DB) |
| Cross-domain | Works — sent in Authorization header | Does not work — cookies are domain-scoped |
| Size | ~500–2000 bytes (grows with claims) | ~32–128 bytes (just a random ID) |
| XSS risk | High if stored in localStorage | Lower with HttpOnly flag |
| CSRF risk | None if sent in headers | Higher — mitigate with SameSite, CSRF token |
| Server lookup per request | No — signature verification only | Yes — session store query each request |
| Best for | APIs, microservices, SPAs, mobile apps | Traditional web apps, monoliths, same-domain |
| Logout complexity | Complex — client discards token (not guaranteed) | Simple — delete session from store |
Quick Decision
Use JWT when…
- You're building a stateless API consumed by mobile or SPA clients
- You need to authenticate across multiple domains or services
- You're in a microservices architecture with independent auth verification
- You want horizontal scaling without sticky sessions or shared store
- You're issuing short-lived access tokens (15 min) with refresh token rotation
- Third-party services need to verify your user's identity (OAuth flows)
Use Session Cookies when…
- You're building a traditional server-rendered web app (Rails, Django, Laravel)
- Instant session revocation is a hard requirement (security incident response)
- Your app lives on a single domain — no cross-domain auth needed
- You want the simplest, most battle-tested auth implementation
- You're building an admin panel or internal tool with strict logout requirements
- You have an existing session infrastructure you don't want to replace
Deep Dive
JWT — JSON Web Token
A JWT (RFC 7519) has three base64url-encoded parts separated by dots: header.payload.signature. The header specifies the signing algorithm (HS256, RS256). The payload contains claims: standard (sub, exp, iat) and custom (user roles, tenant ID). The signature is HMAC-SHA256 of header+payload using a secret key (symmetric) or RSA/ECDSA private key (asymmetric).
Verification is computationally cheap: compute the expected signature, compare with the token's signature. If they match, the token is authentic and unmodified. No database call needed. This is JWTs' architectural advantage: a microservice in Tokyo can verify a JWT issued in Mumbai without calling back to the auth service.
The security footgun: never use alg: "none" (disables signature). Always validate the alg header against an allowlist — early JWT libraries had a vulnerability where attackers could set alg: "none" and skip signature verification entirely.
Session Cookies
Session-based auth stores user state on the server. On login, the server creates a session record in a database or in-memory store (Redis) containing user ID, permissions, and metadata. It sends the client a cookie with a cryptographically random session ID (e.g., 128-bit UUID). On every subsequent request, the browser automatically sends this cookie, and the server looks up the session ID to retrieve user state.
The key advantage: instant revocation. Logout means deleting the session record. Compromised account? Delete all sessions for that user. Security breach? Invalidate all sessions globally. JWTs cannot achieve this without a blocklist — which reintroduces the server-side state that JWTs were designed to eliminate.
Critical security flags: HttpOnly (JavaScript cannot read the cookie — prevents XSS theft), Secure (only sent over HTTPS), SameSite=Lax or Strict (prevents cross-site request forgery). All three should always be set in production.
Real-World Patterns
The SPA + API Pattern (JWT)
A React SPA authenticates against a Node.js API. The SPA is served from app.example.com; the API lives at api.example.com or api.different-domain.com. Session cookies can't cross domains — the browser won't send app.example.com's cookies to api.different-domain.com. JWTs stored in memory (or an HttpOnly cookie if same domain) and sent as Authorization: Bearer tokens work cleanly. Refresh tokens in HttpOnly cookies provide security; access JWTs in memory prevent XSS token theft. This is the standard modern auth architecture for SPAs.
The Microservices JWT Chain
A user authenticates with an API gateway that issues a JWT (expiry: 15 min) signed with RS256. The gateway's public key is distributed to all microservices. Service A calls Service B with the JWT in the request header. Service B verifies the JWT signature locally, extracts the user ID and roles, and proceeds — no call to a central auth service. If Service A is compromised, JWTs expire in 15 minutes regardless. The blast radius of a compromised service is time-bounded by the token expiry, unlike session cookies where the attacker has indefinite access until the session is manually revoked.
The Admin Panel Session (Cookies)
An internal admin panel at admin.company.com uses server-rendered Django with session cookies. When a suspicious admin login is detected from an unusual IP, the security team can instantly invalidate that admin's session by deleting the session record from the Redis store. With JWTs, they'd need to wait for token expiry (or implement a blocklist). For admin tools where security incident response time matters — and where all users are on the same domain — session cookies are simpler, more secure, and provide the immediate revocation that high-security contexts require.
The localStorage Mistake
A common beginner mistake: storing JWTs in localStorage for simplicity. Any XSS vulnerability — including via a compromised npm dependency — can steal the JWT and use it until expiry. The attacker logs in as the user from their server, and the victim has no way to invalidate the token. The correct pattern: store the access JWT in memory (lost on page refresh — user re-authenticates silently via refresh token), store the refresh token in an HttpOnly Secure SameSite=Lax cookie. This combines JWT statelessness with cookie security.
Verdict: JWTs for APIs, Sessions for Web Apps
If you're building a REST or GraphQL API consumed by a mobile app or SPA that might live on a different domain — JWT with short expiry and refresh token rotation is the right architecture. If you're building a traditional server-rendered web application on a single domain with strict logout requirements — session cookies are simpler and more secure by default.
Many modern applications need both: JWTs for machine-to-machine API authentication and microservice auth; HttpOnly session cookies for the primary user-facing web application. Don't treat this as an either/or at the organisation level.
Decision Checklist
| Scenario | Choose |
|---|---|
| SPA authenticating to API on different domain | JWT |
| Traditional server-rendered web app (single domain) | Session cookie |
| Microservices needing stateless auth propagation | JWT |
| Admin panel with instant logout requirement | Session cookie |
| Mobile app authenticating to REST API | JWT |
| Security incident requiring immediate all-user logout | Session cookie |
| Machine-to-machine API authentication | JWT |
| Simple monolith with Rails, Django, or Laravel | Session cookie |
| Third-party OAuth 2.0 integration | JWT (access token) |
| User data needs to travel with the token | JWT (with claim limits) |
| You need to store tokens in the browser securely | JWT in HttpOnly cookie |
| You need to share auth state across subdomains | Session cookie (domain=.example.com) |
Frequently Asked Questions
Can JWTs be revoked before they expire?
Not natively — this is the fundamental trade-off of stateless tokens. A JWT is valid until its expiry (exp claim) regardless of server-side state. To approximate revocation: (1) use short expiry (15 minutes) with refresh tokens, (2) maintain a server-side token blocklist (denylist) and check it on each request — but this reintroduces statefulness. If immediate revocation is a hard requirement (logout on security incident, user account suspension), session cookies or a token blocklist with a fast store (Redis) are the correct choice. JWTs are not ideal for use cases requiring instant, guaranteed revocation.
Are JWTs secure to store in localStorage?
No — storing JWTs in localStorage is a significant security risk. localStorage is accessible to any JavaScript running on the page, making it vulnerable to XSS (Cross-Site Scripting) attacks. Any injected script (via compromised dependencies, user-generated content, or browser extensions) can read the JWT and exfiltrate it. The preferred storage for authentication tokens is an HttpOnly cookie (inaccessible to JavaScript), which eliminates XSS token theft. If you must store JWTs in the browser for CORS-heavy multi-domain scenarios, use in-memory storage (application state) that is lost on page refresh.
What is the difference between a JWT and a session ID?
A session ID is an opaque random string (e.g., 128-bit UUID) that maps to user state stored server-side in a database or memory store. The server performs a lookup on every request. A JWT is a self-contained token with base64url-encoded JSON payload including user claims (user ID, roles, expiry) and a cryptographic signature. The server validates the signature without any database lookup. Session IDs are meaningless without the server store; JWTs carry their own payload and are verifiable by any server with the secret or public key.
How does JWT work across microservices?
JWTs shine in microservices architectures. After authentication, a gateway or auth service issues a JWT signed with a private key. Every downstream microservice validates the JWT using the corresponding public key — no inter-service call to a central session store required. Each service independently verifies user identity and claims from the token. This eliminates a central auth service as a single point of failure and bottleneck. Session cookies require every service to query a shared session store, creating infrastructure coupling and potential performance bottlenecks at scale.
What should be stored in a JWT payload?
Store the minimum necessary claims: user ID (sub), roles/permissions, expiry (exp), issued-at (iat), and any domain-specific claims needed for authorisation decisions (tenant ID, feature flags). Never store: passwords, raw PII beyond what's necessary, sensitive medical or financial data, or secrets. JWT payloads are base64url encoded — not encrypted — meaning they are readable by anyone who has the token (though not forgeable without the signing key). If you need to store sensitive data in a token, use JWE (JSON Web Encryption) rather than plain JWT.
Do session cookies work across multiple domains?
No. Cookies are domain-scoped by the browser's Same-Origin Policy. A session cookie set by app.example.com is not sent to api.other-domain.com. This makes session cookies unsuitable for architectures where the front-end (app.yourdomain.com) must authenticate to an API on a different domain. JWTs sent in Authorization headers work across any domain, making them the natural choice for multi-domain or SPA-to-API architectures. For same-domain monolithic apps (front-end and API on the same domain), session cookies are simpler and more secure.
How do refresh tokens work with JWTs?
Refresh tokens solve the JWT expiry problem. Access JWTs are issued with a short expiry (15 minutes to 1 hour). A long-lived refresh token (days to weeks) is stored securely (ideally as an HttpOnly cookie). When the access JWT expires, the client sends the refresh token to a dedicated endpoint; the server validates the refresh token (stateful — stored in DB), issues a new access JWT, and optionally rotates the refresh token. Rotating refresh tokens (each use issues a new refresh token and invalidates the old one) provides revocation capability and detects token theft through refresh token reuse detection.
Which is more vulnerable to CSRF attacks?
Session cookies are more vulnerable to CSRF (Cross-Site Request Forgery). Because browsers automatically include cookies in cross-site requests, a malicious page can trigger authenticated requests on behalf of a logged-in user. Mitigations: SameSite=Strict or SameSite=Lax cookie attribute (prevents cross-site cookie sending), CSRF tokens in form submissions, or double-submit cookie pattern. JWTs in Authorization headers are immune to CSRF — browsers do not automatically include Authorization headers in cross-site requests. However, JWTs stored in cookies are just as CSRF-vulnerable as session cookies unless SameSite is set.
Related Comparisons
Verdict: Choose Based On Your Situation
JWT (JSON Web Token)
- You're building stateless APIs
- You need to work across multiple domains
- You want self-contained verified tokens
- You're building mobile apps
Session Cookies
- You're building traditional web applications
- You want simpler server-side state
- You need automatic browser handling
- You're within single domain