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 sessionsJWT
StateStored server-side (Redis)Stateless, self-contained
RevocationInstant — delete the recordHard — valid until expiry
ScalingNeeds a shared storeNo lookup needed
Best forWeb apps, admin panelsService-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 it

Example

Example · javascript
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.

Example · js
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.

Example · js
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.

Example · js
// 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

  • Be the first to comment on this lesson.