Sessions vs JWT
Server-side sessions are revocable and simple; JWTs are stateless and scale flat — the right choice depends on whether you can log someone out instantly.
This is the auth decision every Express developer eventually argues about. Both prove identity across stateless HTTP; they trade off differently.
| Server sessions | JWT | |
|---|---|---|
| State | Stored server-side (Redis) | Stateless, self-contained |
| Revocation | Instant — delete the record | Hard — valid until expiry |
| Scaling | Needs a shared store | No lookup needed |
| Best for | Web apps, admin panels | Service-to-service, short-lived access |
The pragmatic middle ground
Most production systems use both: a short-lived (5–15 min) access JWT for cheap, stateless request auth, plus a long-lived refresh token stored server-side (so it can be revoked) in an httpOnly cookie. You get JWT's per-request speed and sessions' "log out everywhere" button.
const access = jwt.sign({ sub: user.id }, SECRET, { expiresIn: '15m' });
// refresh token is opaque, random, and stored so you can revoke itExample
import jwt from 'jsonwebtoken';
import { randomBytes, createHash } from 'node:crypto';
const ACCESS_TTL = '15m';
const { JWT_SECRET } = process.env;
// Pretend store of hashed, revocable refresh tokens.
const refreshStore = new Map(); // tokenHash -> { userId, expires }
function issueTokens(res, user) {
const accessToken = jwt.sign({ sub: user.id }, JWT_SECRET, { expiresIn: ACCESS_TTL });
// Opaque refresh token — store only its HASH, never the raw value.
const refreshToken = randomBytes(32).toString('hex');
const hash = createHash('sha256').update(refreshToken).digest('hex');
refreshStore.set(hash, { userId: user.id, expires: Date.now() + 7 * 864e5 });
res.cookie('rt', refreshToken, {
httpOnly: true, secure: true, sameSite: 'strict', path: '/auth/refresh',
});
return accessToken; // returned in the JSON body, kept in memory client-side
}
// Revocation is trivial because refresh tokens ARE server state.
function revoke(refreshToken) {
const hash = createHash('sha256').update(refreshToken).digest('hex');
refreshStore.delete(hash); // instant 'log out everywhere'
}
// Access-token guard — the stateless, per-request fast path.
export function authenticate(req, res, next) {
const token = req.get('authorization')?.split(' ')[1];
try {
req.user = jwt.verify(token, JWT_SECRET);
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
}When to use it
- A traditional web app that renders HTML on the server uses sessions because the browser handles cookies automatically and no client-side token management is needed.
- A React SPA backend uses JWTs so the frontend can store the token and include it in API requests without relying on server-side session storage.
- A microservices architecture uses JWTs so services can verify identity without calling a central session store, reducing inter-service latency.
More examples
Session-based auth flow
Server stores the user ID in a session cookie; subsequent requests are authenticated by reading req.session.userId.
const session = require('express-session');
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }));
app.post('/login', async (req, res) => {
const user = await db.users.verify(req.body.email, req.body.password);
if (!user) return res.status(401).json({ error: 'Bad credentials' });
req.session.userId = user.id;
res.json({ ok: true });
});
app.get('/me', (req, res) => {
if (!req.session.userId) return res.status(401).json({ error: 'Not logged in' });
res.json({ userId: req.session.userId });
});JWT-based auth flow
Server issues a self-contained JWT; the client stores it and sends it in the Authorization header — no server-side session storage needed.
const jwt = require('jsonwebtoken');
app.post('/login', async (req, res) => {
const user = await db.users.verify(req.body.email, req.body.password);
if (!user) return res.status(401).json({ error: 'Bad credentials' });
const token = jwt.sign({ sub: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '1h' });
res.json({ token });
});
app.get('/me', (req, res) => {
const token = (req.headers.authorization || '').replace('Bearer ', '');
try { const payload = jwt.verify(token, process.env.JWT_SECRET); res.json(payload); }
catch { res.status(401).json({ error: 'Invalid token' }); }
});Hybrid: JWT access + session refresh
Combines both patterns: a short-lived JWT for API calls and a server-side session to issue new JWTs without re-authentication.
// Issue short-lived access token + session-stored refresh token
app.post('/login', async (req, res) => {
const user = await db.users.verify(req.body.email, req.body.password);
if (!user) return res.status(401).json({ error: 'Unauthorized' });
req.session.userId = user.id; // refresh capability in session
const access = jwt.sign({ sub: user.id }, process.env.JWT_SECRET, { expiresIn: '15m' });
res.json({ access });
});
Discussion