JWT vs session cookies
On this page
TL;DR. For most web applications, session cookies are the safer default. Use JWTs when you have a real reason: cross-service auth, stateless APIs, or systems where database-lookup-per-request is a measured bottleneck. The hybrid pattern (HttpOnly cookie containing a JWT) gets you most of both worlds.
How they differ
| Session cookie | JWT (typical) | |
|---|---|---|
| Stored where? | Server-side DB or cache | The token itself carries data |
| Cookie or header? | Cookie | Either; commonly Authorization: Bearer |
| Per-request lookup | DB read | Just signature verification |
| Size | ~32-byte ID | ~300–800 bytes |
| Revocation | Delete from DB → invalid | Hard — see below |
| Cross-service auth | Needs shared session store | Verify with public key, done |
| XSS exposure | None (HttpOnly cookie) | High (often in localStorage) |
| Library maturity | Mature, boring | Mature, has had famous bugs |
Where session cookies win
- Trivial revocation. Logout, force-logout-everywhere, security incident: delete the row. Done.
- Built-in expiry. Browser handles cookie expiry; no clock-skew weirdness.
- Smaller wire footprint. A session cookie is a single short ID; the data lives on the server.
- HttpOnly by default. A session cookie marked HttpOnly is invisible to JavaScript — XSS can’t steal it.
- Familiar. Every web framework has session middleware that’s been battle-tested for 20+ years.
- Doesn’t reveal data on capture. A captured session cookie is a meaningless ID; an attacker still can’t see the user’s email or roles without compromising the server too.
Where JWTs win
- Stateless verification. Every server in a fleet can verify a JWT without consulting a session store. Big deal for distributed systems.
- Cross-service auth. Service A signs; services B, C, D verify with A’s public key. No shared session store needed.
- OIDC / SSO. OpenID Connect uses JWTs for ID tokens. If you’re consuming OIDC, you’re working with JWTs whether you like it or not.
- Mobile / native clients. Cookies are awkward outside browsers.
JWTs in
Authorizationheaders are first-class for mobile / desktop / CLI tools. - Short-lived access tokens. OAuth 2.0 access tokens benefit from the JWT format because resource servers can validate locally.
Where JWTs hurt
- Revocation is hard. A stolen JWT is valid until expiry. Strategies:
- Short expiry (15 min) + refresh tokens. Workable but adds complexity.
- Blocklist of revoked
jtiIDs in a DB. You’ve reintroduced the server-side state JWTs were meant to avoid. - Key rotation. Invalidates all current tokens — too blunt for most cases.
- Storage exposure. localStorage / sessionStorage are JS-readable → vulnerable to XSS. The mitigation (HttpOnly cookies) gives you the cookie’s safety properties — at which point you’ve reinvented sessions with extra steps.
- Bloated requests. A 1KB JWT on every request adds up — especially for SPAs that fire many requests per page load.
- Famous security bugs.
alg: none, RS256-as-HS256 confusion, weak HMAC secrets. These have hit major libraries and apps. Sessions have a much smaller attack surface.
The hybrid pattern (what production typically does)
The cleanest pattern for a typical web app:
- Cookie-based session at the auth layer. When the user logs in, the auth service issues a session cookie (HttpOnly, Secure, SameSite=Lax) to the browser. The cookie is tied to a row in a sessions table.
- The browser sends the cookie automatically with every same-site request.
- Internal RPC between services uses short-lived JWTs minted from the active session, signed by the auth service.
- Other services verify the JWT with the auth service’s public key — no session-store lookup required.
This way:
- Browsers hold a safe HttpOnly session cookie (XSS-resistant).
- Services scale horizontally with stateless JWT verification.
- Revocation works at the session level (delete the row).
- Cross-service authorization uses JWT’s verifiable claims.
The user’s browser never sees a JWT. JWTs live entirely in service-to-service communication.
Mobile and native clients
For non-browser clients, the calculus shifts:
- Cookies are awkward (manual cookie jar handling, cross-platform inconsistencies).
- Mobile apps can store tokens in OS-level keychains (iOS Keychain, Android Keystore), avoiding the localStorage problem.
- The “stateless” benefit is more relevant — mobile apps make a lot of API calls.
A common pattern: opaque refresh token in keychain, short-lived JWT access token in memory only. The JWT gets refreshed when it expires; the refresh token rotates with each use.
Decision flowchart
Are you building a single-server / single-service web app?
└── Session cookies (simpler, safer)
Do you have many backend services that need to authenticate users?
└── JWT for service-to-service; cookie at the browser edge
Are you building a mobile / native / CLI app?
└── JWT (cookies are awkward)
Are you consuming OIDC / OAuth?
└── You're getting JWTs — verify them properly
Do you need easy logout / kick-everywhere?
├── Cookies → trivial
└── JWTs → short expiry + refresh tokens, plus blocklist
Is per-request DB lookup a measured bottleneck?
├── Yes → JWT
└── No → cookies (this is rarely the bottleneck for normal apps)
Common mistakes
- Storing JWT in localStorage. XSS-readable. Use HttpOnly cookies or in-memory only.
- Using a JWT as a long-lived session token. Defeats the statelessness benefit (you have to track revocation anyway) and exposes you to all the JWT pitfalls.
- Putting full user profile in JWT claims. Payload bloat. Put just what authorization decisions need; look up the rest from the database.
- Trusting the alg field of incoming JWTs. Pin expected algorithms in the verifier; don’t let attackers choose.
- **Reinventing JWT. ** Don’t write a JWT library. Use
jose,PyJWT, or your language’s standard. Custom crypto is where bugs hide.
Try the tools
- JWT decoder — inspect any JWT
- JWT verify — check signature
- What is a JWT? — fundamentals
- Standard claims — every registered claim explained
Related across the network
- hash.tooljo.com/which-hash-function — for the server-side question of how to hash session-store passwords (argon2id / bcrypt; not SHA).
- password.tooljo.com — generate the HMAC secret for HS256 (32+ chars random, treat as a key).
- guid.tooljo.com/uuid-v7 — server session IDs should be opaque UUIDs (v4 or v7), not user-meaningful.
- epoch.tooljo.com — JWT
expand session TTL math.