Lesson 01 of 04

Storing passwords

Beginner8 min

The short version: Argon2id, or bcrypt if you must. The long version explains why every other answer is wrong.

OutcomesAfter this you will be able to

  • Explain why SHA-256 is the wrong tool for passwords
  • Pick and tune a password hash with reasonable parameters
  • Avoid the timing leak in your login handler

01You want the slowest hash you can tolerate

SHA-256 is a fine hash. That is the problem — it is designed to be fast, and a modern GPU computes billions of them per second. Against a stolen database, fast means every common password falls in minutes.

Password hashes are deliberately expensive. Argon2id additionally requires a configurable amount of memory per hash, which is what defeats GPU and ASIC attacks — you can parallelise arithmetic cheaply, but not RAM.

  • Argon2id — the current recommendation. Around 19MB of memory, 2 iterations, parallelism 1 is a sensible starting point.
  • scrypt — also memory-hard, fine, available in Node's standard library.
  • bcrypt — old but still acceptable. Cost factor 12 or higher. Note it silently truncates input at 72 bytes.
  • Anything else — MD5, SHA-x, "salted SHA", your own construction — is wrong.

02What the handler looks like

ts
import { hash, verify } from '@node-rs/argon2';

const params = { memoryCost: 19456, timeCost: 2, parallelism: 1 };

export async function register(email: string, password: string) {
  // the salt is generated internally and stored inside the output string.
  // you do not manage it, and you do not need a separate column.
  const passwordHash = await hash(password, params);
  await db.insert(users).values({ email, passwordHash });
}
ts
export async function login(email: string, password: string) {
  const user = await db.query.users.findFirst({ where: eq(users.email, email) });

  // hash against a dummy when the user is missing, so both paths
  // cost the same wall-clock time.
  const stored = user?.passwordHash ?? DUMMY_HASH;
  const ok = await verify(stored, password, params);

  // identical response either way — never "no such user"
  if (!ok || !user) return { error: 'Invalid email or password' };
  return { user };
}

03Password rules that help

  • Enforce a minimum length of 12 or more. Length beats character-class rules by a wide margin.
  • Set a maximum around 128 so nobody submits a 4MB string and pins a CPU core.
  • Check against a breached-password list. This blocks more real attacks than any composition rule.
  • Drop the "must contain a symbol" requirement. It reliably produces `Password1!` and nothing else.
  • Never cap length at something small, and never silently truncate — bcrypt users, watch that 72-byte limit.

Exercise

Time your login endpoint

Send 50 login attempts for an email that exists and 50 for one that does not, then compare the median response times. If they differ by more than a few milliseconds, you have a user-enumeration oracle. Fix it with the dummy-hash pattern and measure again.