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 appStore 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.
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."
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 — trivially | No | Never — it's not security, just formatting |
| Encrypt (AES) | Yes — with the key | Yes | No — steal DB + key = all passwords back |
| Hash (Argon2id, bcrypt) | No — one-way | No | YES — 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.)
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.
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.
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.
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
§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 → falseYou 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.Don't invent this. Use a vetted algorithm with sane parameters. OWASP's current ranking:
OWASP's first choice. Memory-hard (resists GPU/ASIC cracking). Min params: 19 MiB memory, 2 iterations, 1 parallelism. Node: the argon2 package.
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 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'); });
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.Q1. A colleague proposes encrypting passwords with AES so they can be decrypted if needed. What's wrong?
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?
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?
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.)
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.
💬 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.