JWT vs Session Cookies: Authentication Comparison and Selection Guide
Every web app needs authentication, and the choice often gets reduced to "JWT or sessions?" That decision matters more than it looks. Get it wrong and you end up unable to revoke stolen tokens, leak sensitive data by accident, blow up the complexity of inter-service auth, or force a simple web app into a stateless architecture that doesn't fit.
This guide breaks down how each mechanism works, where each is strong, and the failure modes you'll hit.
Session Cookies: Server Remembers You
Sessions are the classic approach. The flow:
- User logs in, server validates credentials
- Server creates a session object, stores it in memory, Redis, or a database
- Server sends the session ID (a random string) to the browser via
Set-Cookie - Subsequent requests include the cookie automatically, server looks up the session by ID
- Logout destroys the session object
// Express + express-session, typical setup
const session = require('express-session');
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: true, httpOnly: true, sameSite: 'lax' }
}));
app.post('/login', (req, res) => {
// validate user...
req.session.userId = user.id;
res.send('logged in');
});
app.post('/logout', (req, res) => {
req.session.destroy(() => res.send('logged out'));
});
Key properties:
- Server-side state: every active session consumes storage (memory or external store)
- Instantly revocable: delete from storage and it's gone, no waiting for expiry
- Cookie-based transport: browser handles sending, cross-origin needs SameSite tuning
- Data stays server-side: cookie is just an ID, business data never leaves the server
JWT: Self-Contained Token
JWT (JSON Web Token) encodes the auth state in the token itself. The structure is three Base64URL segments separated by .:
header.payload.signature
- header: algorithm type (e.g.
{"alg":"HS256","typ":"JWT"}) - payload: claims like user ID, role, expiry
- signature: HMAC or RSA signature over
header.payload, prevents tampering
// Issue a JWT
const jwt = require('jsonwebtoken');
const token = jwt.sign(
{ userId: 42, role: 'admin' },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
// Verify
const payload = jwt.verify(token, process.env.JWT_SECRET);
Key properties:
- Stateless: server stores nothing, signature verification is sufficient
- Self-contained: payload carries user info, no DB lookup needed
- Cross-origin friendly: sent in
Authorization: Bearer ...header, no cookie restrictions - Hard to revoke: once issued, valid until expiry
The Core Tradeoff
The two are philosophically opposite. Sessions put trust on the server. JWT puts trust in the token. This produces a cascade of different tradeoffs:
| Dimension | Session Cookie | JWT |
|---|---|---|
| State | Server stateful | Fully stateless |
| Revocation | Immediate | Needs blacklist or short TTL |
| Cross-service | Shared session store | Just verify signature |
| Cross-origin | Cookie config needed | Header works |
| Size | cookie ~20 bytes | token ~500 bytes |
| Default safety | Higher (HttpOnly cookie) | Implementation-dependent |
| Data confidentiality | Private to server | Payload readable by client |
The last row is the most overlooked trap.
Common JWT Security Pitfalls
1. The payload is Base64, not encrypted
Anyone with the JWT can decode the payload — paste one into a JWT Decoder and every claim shows up in plain text. Putting sensitive data (passwords, API keys, personal info) in the payload is effectively plaintext exposure:
# Any JWT decodes like this, no key needed
echo "eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4ifQ" | base64 -d
# {"userId":42,"role":"admin"}
If you need confidentiality, use JWE (JSON Web Encryption), or keep sensitive data out of the JWT entirely.
2. The alg: none attack
Early JWT libraries allowed the alg: none header, meaning the signature was skipped. An attacker could forge any token and claim it didn't need verification. Modern libraries refuse this by default, but misconfiguration (reading the algorithm from the header without an allowlist) still triggers it.
// Dangerous: allow any algorithm
jwt.verify(token, secret); // legacy default in some old libraries
// Safe: pin the algorithm explicitly
jwt.verify(token, secret, { algorithms: ['HS256'] });
3. You can't revoke a JWT mid-flight
A JWT is valid until it expires. If a user logs out but the token was already intercepted, the attacker keeps using it. Workarounds:
- Short TTL: access tokens expire in 15 minutes, paired with a refresh token
- Blacklist: server tracks revoked tokens (this breaks the "stateless" promise)
- Key rotation: rotate keys periodically to invalidate old tokens (affects all users)
4. Where you store the JWT matters
JWT in localStorage is vulnerable to XSS theft. JWT in an HttpOnly cookie is safer from XSS but you have to handle CSRF. No perfect answer, just tradeoffs based on which attack class you prioritize.
When Sessions Are Right
- Traditional web apps: server-rendered, same-origin, need instant logout
- Strong security requirements: finance, healthcare, any case where "kick the user out now" must work
- Simple systems: single service, no cross-service auth needed
- Data the client shouldn't see: everything stays server-side
When JWT Is Right
- Microservices: each service just verifies the signature, no shared session store
- SPA + mobile API: client manages the token, cross-platform and cross-origin
- Third-party API calls: service-to-service with token carrying identity claims (prototype them with a JWT Generator)
- Short-TTL workflows: paired with refresh tokens, access tokens live briefly
Hybrid Patterns
Real systems often mix both. Common shapes:
- JWT for service-to-service, session for browser: API gateway verifies JWT, browser login uses session
- Refresh token + access token: refresh token is stateful (revocable), access token is stateless JWT with short TTL
- Session-to-JWT exchange: login issues a JWT, but the token list lives in a session for batch revocation
Decode And Generate JWTs Locally
Whether you're debugging a token's contents, verifying a signature, or testing JWT issuance logic, you should avoid sending real tokens to an unfamiliar service.
The JWT Decoder and JWT Generator tools perform all operations locally in your browser. Headers and payloads decode in your browser, signature verification uses the Web Crypto API on your machine. Your tokens (especially production tokens that may contain user IDs and permissions) are never transmitted.
# Quick command-line payload decode (no key required)
echo "eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4ifQ" | base64 -d 2>/dev/null || \
echo "eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4ifQ==" | base64 -d
The short version: prefer sessions for traditional web apps. Prefer JWT (with short TTL) for microservices and cross-platform APIs. Never put sensitive data in a JWT payload. If you need instant revocation, don't use pure JWT, use sessions or refresh tokens with a blacklist.
Implementation notes
For the decode-locally step in this article, the decoder keeps a /en/tools/jwt-decoder?token=... deep link: tools like api-tester can jump over with the token and read it straight away. The parameter is read only once at mount, specifically to avoid the two-writer race where the stale URL value keeps overwriting user input.
Related Tools
Related Articles
Post-Quantum Migration Self-Check: From Algorithm Inventory to Hybrid TLS — 12 Things to Do in 2026
A practical self-check framework for PQC migration: why three overlapping regulatory timelines make 2026 the starting line, how to build a cryptographic bill of materials (CBOM), ranking by HNDL exposure, enabling X25519+ML-KEM hybrid TLS, and what crypto-agility means in practice. With a 12-item checklist.
Passkeys, Explained: Why the Server Can No Longer Hold a Copy of Your Password
Passkeys (WebAuthn/FIDO2) deepened: registration vs assertion ceremonies, platform authenticators and roaming keys, synced passkeys, how they relate to passwords/TOTP/JWT, and why origin binding makes phishing resistance structural. Servers store public keys only; note that this site has no dedicated Passkey tool page.
ML-KEM, Explained: How Kyber Got Into Your TLS Handshake
An engineering tour of ML-KEM (FIPS 203, formerly Kyber): Kyber-to-standard naming, encapsulate/decapsulate and IND-CCA, the three parameter sizes, how X25519+ML-KEM feeds the TLS 1.3 HKDF schedule, the ClientHello-bloat middlebox trap, OpenSSL 3.5 / nginx enablement, and browser-side implementation boundaries.