Password Storage
The single highest-impact decision in authentication is how you store passwords — because databases get breached, and when one does, the difference between "annoying" and "catastrophic" is entirely down to this. The rule is simple to state and surprisingly often gotten wrong: never store passwords; store a slow, salted hash of them. This chapter explains every word of that.
Rule Zero: Never Store the Password Itself
You do not need to know a user's password — you only need to verify that someone presenting a password knows the same one. That means you never store the password (plaintext), and you never store something reversible back to the password either. When a user logs in, you apply the same one-way transform to what they typed and compare the results.
Hashing vs Encryption
| Hashing | Encryption | |
|---|---|---|
| Direction | one-way (irreversible) | two-way (reversible with key) |
| Needs a key? | no | yes — and the key can be stolen |
| Verify a password by | hashing input, comparing hashes | (would require decrypting — wrong model) |
| Right for passwords? | Yes | No |
But Not Just Any Hash: Fast Hashes Are the Wrong Tool
Reaching for SHA-256 or (worse) MD5 seems reasonable — they're one-way. The problem: those are fast hashes, designed to digest data quickly. For passwords that speed is a liability, because it lets an attacker who steals the hashes try billions of guesses per second on a GPU.
Two Attacks Fast Hashes Enable
- Brute force / dictionary attacks — try every common password (and variations) against a stolen hash. At billions/sec, weak passwords fall in seconds.
- Rainbow tables — giant precomputed tables mapping common-password hashes back to the password, so the attacker doesn't even compute — they just look up. This works because the same password always produces the same hash. Salting defeats this.
Salt: Defeating Precomputation & Identical-Password Leakage
A salt is a unique, random value generated per user and combined with the password before hashing. It's stored alongside the hash (it's not secret). Two effects, both crucial:
- Defeats rainbow tables — precomputed tables are useless because the attacker would need a separate table for every possible salt. They're forced to attack each hash individually.
- Hides identical passwords — two users with the same password get different hashes (different salts), so a breach doesn't reveal "these 500 accounts all use
password123."
bcrypt.hash(pw, 12) already produces a uniquely-salted hash, and bcrypt.compare(pw, stored) reads the salt back out of the stored string to verify. You don't store the salt separately — it's in there.
Slow Hashes: bcrypt, scrypt, Argon2
The real defence is a password hashing function deliberately engineered to be slow and resource-intensive, with a tunable cost you raise as hardware gets faster:
| Algorithm | Cost is… | Notes |
|---|---|---|
| bcrypt | CPU (work factor / rounds) | battle-tested, ubiquitous, safe default; ~72-byte input limit |
| scrypt | CPU + memory | memory-hard — resists GPU/ASIC better than bcrypt |
| Argon2 | CPU + memory + parallelism | modern winner (PHC 2015); Argon2id is today's recommended choice |
The cost factor is the key tunable: bcrypt's work factor of 12 means 2¹² internal iterations. Each +1 doubles the time. You set it so a single hash takes a noticeable fraction of a second on your server — trivial for one legitimate login, but devastating to an attacker trying billions of guesses. As hardware improves, you raise it. Memory-hardness (scrypt/Argon2) goes further: requiring lots of RAM per guess neutralizes the massive parallelism of GPUs and custom cracking hardware.
Don't Roll Your Own — and Verify in Constant Time
Use a vetted library (bcrypt, argon2 for Node; your framework's built-in). Two operational notes: the library's compare/verify function does a constant-time comparison (to avoid timing leaks), and you should plan to upgrade hashes over time — on successful login, if the stored hash used an old algorithm or low cost, re-hash the password with the current parameters and save it.
Hands-On Exercises
Explain why passwords must be hashed, not encrypted, and why even a one-way hash like SHA-256 is the wrong choice for passwords. Then implement correct storage and verification with bcrypt in Node (hash on signup, compare on login).
Explain how a salt defeats rainbow tables and hides identical passwords, using two users who both chose hunter2. Then explain why a salt alone is not enough and a slow/cost factor is still required.
A breached database is found to store passwords as unsalted MD5. Explain, step by step, how an attacker cracks the bulk of these in minutes, and write the remediation plan (algorithm choice, cost, salting, forced resets, upgrade-on-login). Compare bcrypt/scrypt/Argon2 and recommend one.
Chapter 2 Quick Reference
- Never store the password — store a slow, salted, one-way hash; verify by hashing the input and comparing
- Hash, don't encrypt — encryption is reversible (key can be stolen); hashing is one-way, no key
- Fast hashes (MD5/SHA-256) are wrong for passwords — GPUs try billions of guesses/sec
- Salt = unique random per-user value (stored, not secret) — defeats rainbow tables & hides identical passwords
- Modern password hashes generate & embed the salt automatically in their output string
- Slow hashes:
bcrypt(CPU),scrypt(CPU+memory),Argon2id(modern recommended) — with a tunable cost factor - Cost factor — set so one hash takes a noticeable fraction of a second; raise it as hardware improves; memory-hardness beats GPUs
- Pepper = optional app-side secret (not in DB) for defence in depth; don't roll your own; upgrade hashes on login
- Next chapter: login attacks & defences — brute force, credential stuffing, rate limiting, lockouts