The question everyone gets wrong
“Should I use sessions or JWTs?” is asked as if one is modern and correct and the other is legacy. It isn’t. They solve the same problem — how does the server know who you are on the next request? — with different tradeoffs. JWTs became fashionable and got applied to problems sessions solve better. Let’s cut through it.
The problem both solve
HTTP is stateless. Each request arrives with no memory of the last. After a user logs in, every subsequent request needs to prove “I’m still the person who logged in.” Both approaches answer this; they just store the proof in different places.
Session-based authentication
The server keeps the state. On login, it creates a session record and hands the client an opaque ID.
import session from 'express-session';
import RedisStore from 'connect-redis';
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // JavaScript can't read it — blunts XSS token theft
secure: true, // HTTPS only
sameSite: 'lax', // CSRF mitigation
maxAge: 86400000, // 24h
},
}));
app.post('/login', async (req, res) => {
const user = await authenticate(req.body.email, req.body.password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
req.session.userId = user.id; // stored server-side
res.json({ success: true });
});
app.get('/profile', (req, res) => {
if (!req.session.userId) return res.status(401).json({ error: 'Not logged in' });
res.json({ userId: req.session.userId });
});
The flow: the client holds only a random session ID in a cookie; the actual data lives server-side (in Redis here). Each request, the server looks up the ID and knows who you are.
Strengths:
- Instant revocation — delete the session record and the user is logged out now, everywhere. This is the big one.
- Small client footprint — just an opaque ID; no user data on the client to leak.
- Easy to change — permissions, roles, anything, update immediately because the server owns the state.
Costs:
- Server-side storage — every active session is a record to store and look up. At scale that’s a Redis cluster.
- A lookup per request — usually microseconds against Redis, but it’s a dependency on the hot path.
Token-based authentication (JWT)
The client keeps the state. On login, the server signs a token containing the user’s identity and hands it over. The server stores nothing.
import jwt from 'jsonwebtoken';
app.post('/login', async (req, res) => {
const user = await authenticate(req.body.email, req.body.password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const token = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ token });
});
app.get('/profile', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'No token' });
try {
// No database/store lookup — just verify the signature
const payload = jwt.verify(token, process.env.JWT_SECRET);
res.json({ userId: payload.userId });
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
});
A JWT has three base64 parts — header, payload, signature. The signature proves the server issued it and it hasn’t been altered. The server verifies the signature with its secret; if it checks out, the claims inside are trusted. No lookup.
Strengths:
- Stateless — no session store; any server with the secret can verify any token. Scales horizontally with nothing to share but a key.
- Cross-service — multiple services validate the same token independently, which is why JWTs fit microservices and APIs.
Costs — and this is where teams get hurt:
The JWT revocation problem
A JWT is valid until it expires, by design. The server doesn’t track it, so the server can’t un-issue it. Ask the question that breaks naive JWT auth: a user’s account is compromised — how do you log them out right now?
With sessions: delete the record. Done.
With JWTs: you can’t, directly. The token stays valid until expiry. Your options are all workarounds:
- Short expiry + refresh tokens — access tokens live ~15 minutes, so a stolen one is useful only briefly. A longer-lived refresh token gets new access tokens. But now the refresh token needs revocation, so you’re storing those server-side — which is a session by another name.
- A denylist — store revoked token IDs and check every request against it. Also server-side state, checked on every request — you’ve reinvented sessions, minus their simplicity, and kept the JWT complexity.
This is the single most important thing to understand: “stateless JWT” and “instant logout” are close to mutually exclusive. Every production JWT system that needs real logout ends up with server-side state anyway. If you needed that state regardless, sessions were probably the simpler tool.
The refresh-token pattern in practice
If you do go JWT, this is the standard shape:
function issueTokens(user) {
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = crypto.randomUUID();
// Refresh token IS stored server-side — so it can be revoked
redis.set(`refresh:${refreshToken}`, user.id, 'EX', 7 * 86400);
return { accessToken, refreshToken };
}
app.post('/refresh', async (req, res) => {
const { refreshToken } = req.body;
const userId = await redis.get(`refresh:${refreshToken}`);
if (!userId) return res.status(401).json({ error: 'Invalid refresh token' });
const user = await getUser(userId);
res.json(issueTokens(user)); // rotate: issue a fresh pair
});
// Logout revokes the refresh token; the access token still
// dies on its own within 15 minutes.
app.post('/logout', async (req, res) => {
await redis.del(`refresh:${req.body.refreshToken}`);
res.json({ success: true });
});
Notice what happened: to get real logout, the refresh token lives server-side. You’ve accepted a 15-minute revocation delay on the access token in exchange for statelessness on the read path. That’s a legitimate tradeoff — just go in knowing you made it, not believing you got stateless auth for free.
Where to store the token (client side)
This trips people constantly:
localStorage— convenient, but readable by any JavaScript, so an XSS bug hands your token straight to the attacker. Common, and commonly regretted.httpOnlycookie — JavaScript can’t touch it, so XSS can’t read it. But cookies are sent automatically, which reopens CSRF, so you needsameSiteand/or CSRF tokens.
There’s no free option — you’re choosing which attack class to defend against. An httpOnly, secure, sameSite cookie is the more defensible default for most web apps, because XSS token theft tends to be the more damaging and more common failure.
Security fundamentals (both approaches)
No auth scheme survives these being wrong:
Hash passwords properly. bcrypt, scrypt, or argon2 — never MD5, SHA-256, or (obviously) plaintext.
import bcrypt from 'bcrypt';
const hash = await bcrypt.hash(password, 12); // on signup
const ok = await bcrypt.compare(password, user.hash); // on login
Always HTTPS. Either a session cookie or a bearer token sent over plain HTTP can be sniffed and replayed. Non-negotiable.
Rate-limit login. Without it, attackers brute-force passwords freely.
A long, random secret, from the environment, never in code. If your JWT secret leaks, an attacker forges valid tokens for any user — game over.
The honest recommendation
Default to sessions for a traditional web app with a single backend. They’re simpler, revocation is trivial, and the “scaling problem” is theoretical for the vast majority of apps — a Redis session store handles enormous traffic before it’s a concern. Most teams reaching for JWTs “to scale” are solving a problem they don’t have while taking on the logout problem they’ll definitely have.
Reach for JWTs when you have the specific problem they solve: stateless verification across multiple services, third-party API access, or a mobile/SPA client hitting an API where cookies are awkward. Then the tradeoffs are worth it because you genuinely need what JWTs offer.
Don’t choose based on what’s trendy. JWTs are not “the modern way” and sessions are not “legacy.” They’re different tools. The right question isn’t “which is better” — it’s “do I need stateless, cross-service verification badly enough to accept the revocation complexity?” If yes, JWT. If you’re not sure, the answer is no, and sessions will serve you better with less to get wrong.
