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.
relatedTools
Related Articles
Online HMAC Generator: Message Authentication Code Explained
Learn how HMAC (Hash-based Message Authentication Code) works. Understand HMAC-SHA256 workflows, real-world use cases, and how to generate and verify HMAC signatures online.
SHA-256 Hash Algorithm: How It Works, Applications, and Online Tools
Understand SHA-256 internals, comparison with MD5/SHA-1, and real-world applications in password storage, blockchain, and file integrity.
Online Bcrypt Hash Tool: The Gold Standard for Password Hashing
Learn how Bcrypt password hashing works. Understand cost factors, salt generation, and how to use an online tool to generate and verify Bcrypt hashes.