Back to articles index
Format comparisons

JWT vs session cookie — how should your app track login state?

Compare stateless JWTs and server-side session cookies for web auth across four axes: server-side state, revocation, scalability, and risk. Includes the different CSRF and XSS threat models each implies.

Four axes — state, revocation, scale, attack surface

“JWT or session cookie?” gets debated as a religious war, but the substance comes down to four trade-offs. Server-side state decides whether you keep authentication data stateless (JWT) or stateful (sessions). Revocation decides how quickly you can kill an issued credential after a password change or a compromise. Scalability captures the cost of sharing auth across multiple servers or microservices. Attack surface decides whether XSS or CSRF deserves more of your defensive budget.

“JWT is modern, sessions are legacy” is wrong. Google and Amazon still use session-based authentication for many internal services, and the right choice depends on requirements. SPAs talking to microservices lean naturally toward JWT; a traditional monolithic web app is happier with session cookies. The way to choose is to work backwards from your architecture and threat model, not from fashion.

Side-by-side comparison

PropertyJWT (Bearer Token)Session Cookie
StateStatelessStateful (server holds the data)
Stored whereAuthorization: Bearer ... header / localStorage / CookieCookie (HttpOnly; Secure; SameSite)
Where state livesOn the client (payload is the token)Redis / memory / database
Instant revocationHard (needs a blocklist)Easy (delete server-side)
Token size200-1000 bytes32-128 bytes (session id only)
Verification costSignature check only (no DB)Database / Redis lookup required
Main attack vectorXSS-stolen tokens, alg: none attacksCSRF (mitigated by SameSite)
Infrastructure neededPrivate/public keySession store (Redis, etc.)
SPA / mobile fitGood (no cookies required)Cookies need careful setup
Cross-domain sharingEasy (Authorization header)Needs CORS + SameSite=None; Secure

For a size reference: a session cookie like sid=abc123... is 32-64 characters, while a JWT carries header.payload.signature and starts at around 200 bytes, easily passing 1 KB when packed with user claims. That overhead rides on every single request, so API design has to keep the payload minimal.

Recommendations by architecture

Single-server traditional web apps (Rails / Django / Laravel): session cookies. The implementation is simple and a logout button truly guarantees immediate revocation. The framework’s built-in session machinery hands you a CSRF token in the same flow.

Service-to-service API calls in a microservice mesh: JWT. Each service verifies the signature with a public key and never has to call back to an auth server. ID tokens issued by Auth0, Cognito, or Keycloak follow this pattern.

SPA + REST API: a hybrid is standard. Pair a short-lived access token (JWT, around 15 minutes) with a long-lived refresh token (HttpOnly cookie). Send the access token in the Authorization header, swap it for a new one when it expires, and you cover both XSS exposure and revocation.

Mobile apps: JWT. Native HTTP clients tolerate explicit Authorization headers far better than the implicit cookie jar that browsers manage for you.

Finance, healthcare, anywhere instant revocation is non-negotiable: session cookies, or JWT + blocklist. When pressing “freeze account” has to log a user out of every device immediately, the cost of giving up statelessness is worth paying for the safety. A JWT-only design forces you to set a short expiry (often 5–15 minutes) and lean on a blocklist, which reintroduces the database lookups that JWT was supposed to avoid in the first place.

Long-lived “remember me” sessions: session cookies. JWTs become awkward at long expiries because revocation gaps balloon — a 30-day JWT that was leaked on day 1 stays valid for 29 more days unless you blocklist it. Session cookies handle the same use case with a single deletable row.

Verify in the browser, dodge the classic traps

The most common JWT mistake is storing tokens in localStorage. One XSS hole anywhere on the page is enough — localStorage.getItem("token") ships the token to the attacker, and the server has no recourse. Putting the token in an HttpOnly; Secure; SameSite=Strict cookie keeps JS away from it entirely, and XSS resistance jumps a tier. The second classic is the alg: none attack: older JWT libraries trust the header’s alg field and skip verification, letting an attacker pass {"alg":"none"} forgeries straight through.

When you need to confirm whether a JWT in front of you carries the right claims and is genuinely signed, you want tools you can paste into without sending the token off-device. jwt-decode base64url-decodes the header and payload for inspection only — no signature check, debugging-only. The tool to use in real verification logic is jwt-verify, which runs SubtleCrypto.verify against HS256, RS256, or ES256 and checks both the signature and the expiry. When you need a fresh token for testing, jwt-encode lets you sign one with your own key. In every case the token and the key stay inside the browser and never leave it. The source is on GitHub, and the DevTools Network tab confirms zero outbound requests — useful peace of mind when you are pasting production keys to debug.