Sessions vs JWT: an honest comparison
I've watched teams waste weeks debating whether to use sessions or JWT as if one is objectively "modern" and the other "legacy." The reality is that neither label applies. Sessions and JWT are fundamentally different trade-offs, each making sense in different architectural contexts. Understanding when to reach for each one, and when to combine them, is what separates pragmatic engineers from dogmatists.
The core difference: state vs statelessness
Sessions store authentication state on the server. When a user logs in, you create a session object, give them a session ID (usually in a cookie), and look up that session on every request. The server holds the truth.
JWT (JSON Web Tokens) are self-contained. They carry claims about the user encoded and cryptographically signed by the server. The user includes the token in requests, the server verifies the signature, and you extract user information directly from the token. No lookup needed.
This difference cascades into everything else: revocation speed, scalability patterns, where tokens live, and how many services can trust them.
Comparison: the real trade-offs
Server-side state vs stateless tokens
With sessions, your server owns the source of truth. You create a session, store it in memory, Redis, or a database, and the user gets a reference. On every request, you fetch that session. This means:
- You control exactly what's in the session at any moment.
- You can update session data instantly across all your infrastructure (if you use a shared store like Redis).
- Your auth logic is centralized.
- Every request requires a session lookup—a network call or cache hit.
With JWT, the token itself is the contract. The user presents a signed token; you verify the signature and trust the claims inside. This means:
- No session lookup needed—tokens are stateless and verification is cryptographic.
- Claims in the token are fixed until it expires; you can't update them mid-token-lifetime.
- Each service that accepts your JWT can verify it independently without calling you back.
- Revocation requires extra infrastructure (blocklists or callback checks).
Instant revocation vs hard to revoke
This is where sessions shine. A user logs out, you delete the session, and their access is gone immediately. A malicious admin's session ends, boom, they're locked out.
With JWT, if you want to revoke a token before it naturally expires, you need a revocation list. You maintain a Redis set of revoked token IDs (JTI—JWT ID claim), and on every request, check if the token is in the blocklist. That's a network call you're back to doing anyway, which defeats some of the "stateless" advantage.
Real scenario: A contractor's access token is valid for another 2 hours. You fire them on a Friday afternoon. With sessions, their session dies instantly. With JWT, you need a revocation list or you're waiting 2 hours. That's a material difference for security-sensitive operations.
Simple for single web apps vs good across services
If you're running one web application—a traditional monolith or a tightly coupled backend + frontend—sessions are simpler. You set a cookie with HttpOnly and Secure flags, handle sessions in your auth middleware, done. No cryptographic keys to manage, no token refresh logic, no claims to structure.
Across multiple services, JWT wins. A user logs in to your auth service. That service issues a JWT signed with a private key. Your API gateway, your payment service, your notifications service—all can verify that JWT with the public key. No need for those services to call your auth service on every request. Each service independently trusts the token.
Practical middle ground: short-lived tokens plus refresh
Most teams I've worked with land here, and for good reason: use short-lived JWT access tokens (15 minutes) plus longer-lived refresh tokens (7 days).
Here's the flow:
- User logs in; you issue a short-lived access token (JWT) and a refresh token (often a session-like opaque string stored in Redis).
- The access token goes in memory or localStorage; the refresh token goes in an HttpOnly cookie.
- Requests use the access token. No revocation list needed because it expires quickly.
- When the access token expires, the frontend uses the refresh token (sent automatically via cookie) to get a new access token.
- If you need to revoke a user immediately, you invalidate their refresh token in Redis. Their access token still works for a few more minutes, but they can't get a new one.
- Across services, the short-lived access token is still self-contained and stateless.
This hybrid approach gives you the speed of stateless JWT for your hot path (every API request) and the control of sessions for revocation and logout.
// Issuing tokens on login
app.post('/auth/login', async (req, res) => {
const user = await validateCredentials(req.body);
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = generateRandomToken();
await redis.setex(
`refresh:${user.id}:${refreshToken}`,
7 * 24 * 60 * 60, // 7 days
JSON.stringify({ userId: user.id, issuedAt: Date.now() })
);
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken });
});
// Refreshing the access token
app.post('/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
const key = `refresh:${req.user.id}:${refreshToken}`;
const stored = await redis.get(key);
if (!stored) {
return res.status(401).json({ error: 'Refresh token invalid or revoked' });
}
const accessToken = jwt.sign(
{ userId: req.user.id, role: req.user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken });
});
// Logout: revoke refresh token
app.post('/auth/logout', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
await redis.del(`refresh:${req.user.id}:${refreshToken}`);
res.clearCookie('refreshToken');
res.json({ success: true });
});
Storage matters: HttpOnly cookies protect against XSS
Where you store your auth token is a security decision, not just a convenience choice.
HttpOnly cookies: The browser sends them automatically with every request but JavaScript cannot access them. An XSS attack can't steal them. Vulnerable to CSRF, but you defend with SameSite cookies and CSRF tokens on state-changing requests.
LocalStorage or sessionStorage: JavaScript can read them. Convenient for SPAs, but any XSS vulnerability lets attackers steal them. You trade one attack surface (CSRF) for another (XSS).
If you're using JWT, store it in an HttpOnly cookie if your frontend is served by the same domain. If your frontend is a separate domain (common in microservices), you may need to store it in memory or localStorage, which means XSS becomes a critical concern—invest heavily in Content Security Policy (CSP) and input sanitization.
For refresh tokens, HttpOnly cookies are non-negotiable. You want refresh tokens unreachable to XSS.
Choose by architecture
One web app, monolithic backend
Use sessions. Store them in Redis for fast lookups and distributed access. Set a cookie with HttpOnly and Secure flags. Your auth logic is a middleware; revocation is instant. You're not overthinking.
Multiple internal services, shared identity provider
Use JWT access tokens plus refresh tokens. Your auth service issues short-lived JWTs. Your API gateway or service mesh verifies tokens without calling back to auth. Your refresh tokens live in a central Redis, giving you revocation control and logout.
Public API accessed by many clients
Use long-lived or non-expiring JWT access tokens, possibly with a separate API key flow for service-to-service. Your clients can't reliably refresh (they're not your web app), so short-lived tokens cause friction. Accept that immediate revocation is harder; compensate with audit logging and IP whitelisting if the client is high-value.
Mobile app with backend
Short-lived JWT access tokens plus refresh tokens in HttpOnly cookies if you control the backend, or in secure storage if using platform-provided secure storage (Keychain on iOS, Keystore on Android). This balances security and user experience.
Common mistakes
- Storing JWT in localStorage without XSS defenses: You've just made your auth tokens available to every malicious script. Use CSP headers strictly, sanitize user inputs, and consider an HTTPOnly-only approach even if it's slightly less convenient.
- Setting JWT expiry to years: A leaked token is valid forever. Even 30 minutes is better than 1 year. Short-lived tokens force rotation and reduce blast radius.
- Forgetting to sign the JWT: An unsigned token is just base64. Anyone can modify it. Always use asymmetric signing (RS256, ES256) for multi-service scenarios; symmetric (HS256) is fine if only one service issues and verifies.
- Using sessions without a distributed store: If your load balancer sends a user to a different instance, their session is gone. Use Redis or a shared database from day one.
- Revoking sessions but not clearing cookies: The cookie still exists client-side. The user's browser still sends it. Revoke the server-side session and explicitly clear the cookie header in your response.
- Mixing up authentication and authorization: Both sessions and JWT handle authentication (proving who you are). Authorization (what you can do) is your job. Encode role or permissions, but always validate server-side.
Key takeaways
- Sessions store state on the server; JWT is stateless and self-verifying. Neither is "modern"—they're different trade-offs.
- Sessions revoke instantly; JWT requires a revocation list for instant revocation, negating some stateless advantage.
- One service? Sessions are simpler. Many services? JWT reduces inter-service calls.
- Use short-lived JWT access tokens (15 minutes) plus refresh tokens for the best of both worlds: stateless performance and revocation control.
- Store tokens in HttpOnly cookies whenever possible to defend against XSS; only use localStorage if you're confident in your XSS defenses or if the architecture requires it.
- Your architecture dictates your choice. Know your deployment topology before deciding.






Comments
Loading comments…