Kodokon kodokon.com

Authentication: hashing, sessions vs JWT

Hash passwords with a costly function, choose between sessions and JWT with full awareness, and protect your routes with middleware.

10 min · 3 questions

Open this lesson in Kodokon

Storing a password means preparing for a future breach: design the hashing to survive theft of the database. A fast hash like SHA-256 can be brute-forced at billions of attempts per second on a GPU. You need a deliberately costly function - scrypt, provided by node:crypto, or bcrypt/argon2 - and a random per-user salt, which renders precomputed tables useless. The salt is not a secret: it's stored in plain text next to the hash.

JAVASCRIPT
import {
  randomBytes,
  scryptSync,
  timingSafeEqual,
} from 'node:crypto';

export function hashPassword(password) {
  const salt = randomBytes(16).toString('hex');
  const hash = scryptSync(password, salt, 64)
    .toString('hex');
  return `${salt}:${hash}`;
}

export function verifyPassword(password, stored) {
  const [salt, hash] = stored.split(':');
  const candidate = scryptSync(password, salt, 64);
  const expected = Buffer.from(hash, 'hex');
  return timingSafeEqual(candidate, expected);
}
Random salt, costly derivation, constant-time comparison.

Two models for keeping the user logged in. The session: an opaque identifier in an httpOnly cookie, with state on the server side. Immediate revocation, cookie invisible to JavaScript, but a shared store (Redis) becomes necessary as soon as you add a second instance. The JWT: the signed state embedded in the token, verifiable without any store - ideal between services - but irrevocable before expiry. The honest trade-off: sessions for a classic web application, short-lived JWTs for APIs consumed by third parties or for distributed architectures.

JAVASCRIPT
import jwt from 'jsonwebtoken';

const SECRET = process.env.JWT_SECRET;
if (!SECRET) {
  throw new Error('JWT_SECRET must be set');
}

export function signToken(user) {
  return jwt.sign(
    { sub: user.id, email: user.email },
    SECRET,
    { expiresIn: '15m' },
  );
}
Secret required at startup, tokens with a short lifetime.

The protection middleware factors out access control: it extracts the token from the Authorization: Bearer header, verifies it, attaches the decoded identity to req.user, or cuts things short with a 401 routed to the error middleware from the previous lesson. Apply it at the router level (router.use(requireAuth)) to protect an entire group of routes rather than route by route.

JAVASCRIPT
import jwt from 'jsonwebtoken';
import { HttpError } from './http-error.js';

const SECRET = process.env.JWT_SECRET;

export function requireAuth(req, res, next) {
  const header = req.headers.authorization ?? '';
  const [scheme, token] = header.split(' ');
  if (scheme !== 'Bearer' || !token) {
    return next(new HttpError(401, 'Missing token'));
  }
  try {
    req.user = jwt.verify(token, SECRET);
    next();
  } catch {
    next(new HttpError(401, 'Invalid or expired token'));
  }
}
Verify, attach req.user, or 401 - nothing else.

Knowledge check

Make sure you remember the key points of this lesson.

  1. Why is SHA-256 alone unsuitable for storing passwords?
    • Because the algorithm is cryptographically broken
    • Because it's too fast: a GPU tests billions of candidates per second; you need a slow, salted function
    • Because it produces collisions on short passwords
    • Because Node doesn't implement it natively
  2. Which mechanism lets you revoke access immediately, before any expiry?
    • The JWT, because it embeds its expiry date
    • The server-side session, because you just delete its entry from the store
    • Both, equivalently
  3. What is timingSafeEqual for in password verification?
    • To speed up the comparison of the two hashes
    • To compare in constant time so the response duration doesn't reveal how many bytes match
    • To convert the hexadecimal hashes into Buffers