JWT vs Sessions: When to Use What
Neither is universally better — the right choice depends on your architecture.
| JWT | Sessions | |
|---|---|---|
| Storage | Client (localStorage/cookie) | Server (Redis/DB) |
| Scalability | Horizontal (stateless) | Needs shared store |
| Revocation | Hard (short TTL + blacklist) | Easy (delete from store) |
| Payload | Carries data | Just an ID |
| Mobile-friendly | Yes | Requires cookies |
Secure JWT Implementation
// auth/jwt.js
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
const ACCESS_TOKEN_TTL = '15m';
const REFRESH_TOKEN_TTL = '7d';
export function generateTokenPair(userId, role) {
const accessToken = jwt.sign(
{ sub: userId, role, type: 'access' },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: ACCESS_TOKEN_TTL, algorithm: 'HS256' }
);
const refreshToken = jwt.sign(
{ sub: userId, jti: crypto.randomUUID(), type: 'refresh' },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: REFRESH_TOKEN_TTL, algorithm: 'HS256' }
);
return { accessToken, refreshToken };
}
export function verifyAccessToken(token) {
return jwt.verify(token, process.env.JWT_ACCESS_SECRET);
}
export function verifyRefreshToken(token) {
return jwt.verify(token, process.env.JWT_REFRESH_SECRET);
}
Refresh Token Rotation
// routes/auth.js
import { redis } from '../lib/redis.js';
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
try {
const payload = verifyRefreshToken(refreshToken);
const { sub: userId, jti } = payload;
// Check if token was revoked (used or blacklisted)
const isRevoked = await redis.get(`revoked:${jti}`);
if (isRevoked) {
// Possible token theft — invalidate ALL refresh tokens for user
await redis.del(`user:${userId}:refresh_tokens`);
return res.status(401).json({ error: 'Token reuse detected' });
}
// Revoke the used refresh token
await redis.set(
`revoked:${jti}`,
'1',
'EX',
7 * 24 * 60 * 60 // 7 days
);
// Issue new token pair
const { accessToken, refreshToken: newRefreshToken } = generateTokenPair(userId);
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
return res.json({ accessToken });
} catch (err) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
Auth Middleware
export function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
const token = authHeader.slice(7);
try {
const payload = verifyAccessToken(token);
req.user = { id: payload.sub, role: payload.role };
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
// Role-based access
export function requireRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user?.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
Session-Based Auth with Redis
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
name: 'sid',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000,
},
}));
// Login
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await verifyCredentials(email, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Regenerate session to prevent session fixation
req.session.regenerate((err) => {
if (err) return next(err);
req.session.userId = user.id;
req.session.role = user.role;
res.json({ message: 'Logged in' });
});
});
// Logout
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
res.clearCookie('sid');
res.json({ message: 'Logged out' });
});
});
Password Hashing with Argon2
import argon2 from 'argon2';
// Hash password on register
export async function hashPassword(password) {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3,
parallelism: 4,
});
}
// Verify on login
export async function verifyPassword(hash, password) {
return argon2.verify(hash, password);
}
Security Checklist
- Use
httpOnly+secure+sameSitecookies for refresh tokens - Rotate refresh tokens on every use
- Detect token reuse (token theft)
- Use Argon2id for password hashing
- Rate limit auth endpoints (5 attempts / 15 min)
- Log all auth events for audit
- Store minimal data in JWT payload
- Use short-lived access tokens (15min)
- Implement logout (token blacklist or session delete)