auth · Lesson 4 · Node + Express

Passwords Done Right

Lessons 1–3 were about staying logged in. Now we start phase ① — the login itself — with the oldest method and the one you're most likely to get dangerously wrong: passwords. The entire job is summed up in one rule that surprises people: you never store the password, and you never even encrypt it. You hash it. Today you'll see exactly why, and write the ~5 lines that separate a safe store from a breach headline.

🎯 Mission: a mental model of modern auth + the ability to wire it into a real app
The one idea

Store a slow, salted hash of the password — never the password, never an encrypted password. Encryption is reversible (a key turns it back); a hash is a one-way function with no way back. So even when your database is stolen, the attacker gets hashes they must crack one guess at a time — and "salted" + "slow" are the two properties that make that cracking economically hopeless.

01 Why this matters more than it looks

Password handling is where small mistakes become catastrophic and public. The threat model isn't "someone guesses one password" — it's "your entire user table leaks" (it happens constantly), and the question becomes: once an attacker has every row, how many accounts can they open? Everything in this lesson is about making that answer "≈ none, and it'll take them centuries."

💡 Reframe: assume the breach Good password storage is designed for the day your database is already stolen. You're not protecting the DB (that's a different job) — you're making the stolen data useless. That mindset is why "just encrypt the passwords" is wrong: an attacker who took your DB can take your key too. See OWASP Password Storage.

02 Hash ≠ encrypt ≠ encode — the distinction that trips everyone

Three words get used interchangeably and they are not the same. This is the single most important table in the lesson:

Reversible?Needs a key?Use for passwords?
Encode (base64, URL)Yes — triviallyNoNever — it's not security, just formatting
Encrypt (AES)Yes — with the keyYesNo — steal DB + key = all passwords back
Hash (Argon2id, bcrypt)No — one-wayNoYES — there's nothing to reverse

A password hash is a one-way function: easy to compute forwards (password → hash), computationally infeasible to run backwards (hash → password). You store the hash. At login you hash the attempt and compare. The plaintext is never written down and can't be recovered — not even by you. (This is the same "signed, not secret" mental muscle from Lesson 3, applied to a different problem: there, readable but tamper-evident; here, verifiable but irreversible.)

⚠️ The tell of a broken system If a site can email you your actual old password ("here is your password: ●●●"), they stored it reversibly. That's a red flag you can now spot in the wild. A correct system can only ever reset it, never retrieve it.

03 Plain hashing isn't enough: salt + slow

A naive sha256(password) is one-way, but still broken for passwords for two reasons — and the two fixes are the heart of every real password hash.

Problem 1 → Salt

Fast hashes are deterministic: sha256("password123") is always the same string. Attackers precompute that mapping for billions of common passwords (a rainbow table) and just look up your hashes. Worse, two users with the same password get the same hash — visible in the leak. Salt fixes both: a unique random value mixed into each hash, so the same password produces a different hash every time and no precomputed table applies.

Problem 2 → Slow (work factor)

SHA-256 is built to be fast — a GPU does billions per second, so brute-forcing stolen hashes is cheap. Password hashes are deliberately slow and memory-hard, with a tunable cost factor. One login costing 50 ms is invisible to your user but turns an attacker's "billions per second" into a crawl.

password "correct horse…" random salt unique per user SLOW hash Argon2id / bcrypt cost factor ⏳ stored hash $2a$10$salt…hash… ← salt travels inside
The salt isn't a secret — it's stored right inside the hash string. Its job is uniqueness, not concealment. The cost factor is what makes each computation slow.

04 🎯 Your tangible win (4 min): watch salt + slowness happen

A Node lab on your machine hashes the same password twice, times three cost factors, and verifies a guess. Deps installed.

# in your terminal:
cd ~/projects/learn/public/courses/auth/practice/password-lab
npm start
🎯 The win — real output from your machine
§1 same password, hashed twice:
  hash A: $2a$10$1yXjudfgLG5L…
  hash B: $2a$10$3SLfvwKBCsee…   ← different! (unique salt each time)

§2 slow by design — cost factor:
  cost 8:  ~12 ms      cost 10: ~50 ms      cost 12: ~201 ms
  (each +1 roughly DOUBLES the work — invisible to a user, brutal at scale)

§3 verify without storing the password:
  right password → true      wrong password → false
You just watched the two defenses that make a leaked password table near-worthless: identical inputs producing different stored values (salt), and the cost knob you can turn up as hardware gets faster (slowness). Open demo.js, bump COST to 14, and watch the milliseconds climb.

05 What to actually use (2025) + the Express login

Don't invent this. Use a vetted algorithm with sane parameters. OWASP's current ranking:

① Argon2id — the default

OWASP's first choice. Memory-hard (resists GPU/ASIC cracking). Min params: 19 MiB memory, 2 iterations, 1 parallelism. Node: the argon2 package.

② bcrypt — fine, widely available

Battle-tested, everywhere. Use work factor ≥ 10. ⚠️ Caps input at 72 bytes — longer passwords are silently truncated. (The lab uses this for zero-build portability.)

③ scrypt / PBKDF2

scrypt if Argon2 unavailable; PBKDF2 (≥600k iters) only when FIPS compliance forces it. Last resorts.

The whole Express signup + login, with Argon2id, is genuinely this short:

const argon2 = require('argon2');

// SIGN UP — store only the hash
app.post('/signup', async (req, res) => {
  const hash = await argon2.hash(req.body.password, { type: argon2.argon2id });
  await db.users.insert({ email: req.body.email, passwordHash: hash });  // never the password
  res.redirect('/login');
});

// LOG IN — hash the attempt, compare, then start a session (Lesson 2)
app.post('/login', async (req, res) => {
  const user = await db.users.findByEmail(req.body.email);
  const ok = user && await argon2.verify(user.passwordHash, req.body.password);
  if (!ok) return res.status(401).send('Invalid email or password');  // same msg for both!
  req.session.userId = user.id;                                  // ← phase ③ begins
  res.redirect('/account');
});
💡 See the handoff? The password's entire job ends at req.session.userId = user.id — that's the moment phase ① (login) hands off to phase ③ (stay logged in) from Lessons 1–2. Every login method in this course ends at that same line. The password just earned the session.

06 The footguns that cause real breaches

Rolling your own (sha256 + salt)Use a library. Fast hashes are crackable even salted; you need slow. Argon2id/bcrypt exist so you don't improvise.
Different errors for "no such user" vs "wrong password"One generic message + constant-ish time. Distinct responses let attackers enumerate which emails are registered.
No rate limiting on /loginThrottle + lock out. Slow hashing fights offline cracking; rate limits fight online guessing. You need both.
No length cap / huge inputsCap input length (e.g. ≤128 chars) to prevent DoS via giant passwords, and know bcrypt's 72-byte limit.
⚠️ "Password complexity rules" are mostly obsolete Modern guidance (NIST 800-63B): allow long passphrases, check against known-breached password lists, and stop forcing periodic resets and "1 symbol + 1 number" rules — they push users toward weaker, predictable patterns. Length > complexity theater.

07 Check yourself

Q1. A colleague proposes encrypting passwords with AES so they can be decrypted if needed. What's wrong?

Show answer

Encryption is reversible with the key — and an attacker who steals your database will very likely steal the key too (config, env, same infra). Then every password is recovered. Passwords must be hashed (one-way, no key, nothing to reverse), so a stolen DB yields only crack-resistant hashes. "If needed" is itself the red flag — you should never need the plaintext. (§02.)

Q2. Why is a unique salt per user necessary even though salts are stored in plain sight next to the hash?

Show answer

Salt's job is uniqueness, not secrecy. It defeats precomputed rainbow tables (the attacker can't precompute for an unknown random salt) and ensures two users with the same password have different hashes, so the leak reveals nothing by pattern-matching. It being public doesn't matter — the attacker still has to crack each hash individually and slowly. (§03, §04 §1.)

Q3. Your login returns "no account with that email" for unknown emails and "wrong password" for known ones. Why is that a problem, and what's the fix?

Show answer

It's a user-enumeration oracle: an attacker can discover which emails are registered, then target them (credential stuffing, phishing). Fix: one generic message ("Invalid email or password") for both cases, and avoid timing differences that leak which branch ran. (§06.)

08 Where we go next

You can now run the full password login safely. But notice what passwords cost: every site needs its own store, users reuse passwords across sites (so one breach cascades), and you're on the hook for all of it. That pain is exactly what OAuth2 was built to relieve — "let users log in with an account they already have, and never touch their password." Lesson 5 opens the authorization-code flow: why all those redirects, and what's actually being handed between sites.

📌 Remember this one line Never store the password — store a salted, slow one-way hash (Argon2id, or bcrypt ≥10). Hashing ≠ encryption: there's no key and no way back, which is the whole point when your database is stolen.

💬 Ask me anything — e.g. "show the Argon2id lab instead of bcrypt", "add rate limiting to the Express login", or "how does a password reset flow work safely?" · Back to Lesson 3 · Resources.