Password Storage

Chapter 2
Password Storage Done Right
Hashing vs encryption, salts, peppers, and slow hashes (bcrypt / scrypt / Argon2)

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.

Plaintext and "encrypted" passwords are both wrong — and people confuse the second for safe
Storing plaintext is obvious negligence. The subtler mistake is encrypting passwords — which sounds secure but isn't appropriate here. Encryption is reversible: it needs a key, and whoever has the key (an attacker who breaches the server, a malicious insider) can decrypt every password back to plaintext. Passwords should be hashed, not encrypted, precisely because hashing is one-way — there's no key to steal and no way back. "We encrypt passwords" in a post-breach statement is a red flag, not reassurance.

Hashing vs Encryption

HashingEncryption
Directionone-way (irreversible)two-way (reversible with key)
Needs a key?noyes — and the key can be stolen
Verify a password byhashing input, comparing hashes(would require decrypting — wrong model)
Right for passwords?YesNo

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.

// WRONG — a fast general-purpose hash const hash = sha256(password); // GPUs try billions/sec against this // RIGHT — a deliberately slow password hash (bcrypt) const hash = await bcrypt.hash(password, 12); // tuned to be slow

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:

// each user gets a unique random salt; stored with the hash salt = randomBytes(16) stored = hash(salt + password) + ":" + salt
  • 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."
Good news: modern password hashes salt for you
You rarely manage salts by hand. bcrypt, scrypt, and Argon2 generate a random salt automatically and embed it in the output string — the hash itself contains the algorithm, cost parameters, salt, and digest, all in one self-describing value. So 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:

AlgorithmCost is…Notes
bcryptCPU (work factor / rounds)battle-tested, ubiquitous, safe default; ~72-byte input limit
scryptCPU + memorymemory-hard — resists GPU/ASIC better than bcrypt
Argon2CPU + memory + parallelismmodern 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.

Pepper — an optional extra layer
A pepper is a secret value added to the password before hashing — like a salt, but the same for all users and not stored in the database (kept in the app config / a secrets manager / an HSM). The idea: if only the database is breached (but not the app secrets), the attacker is missing the pepper and can't crack the hashes at all. It's a defence-in-depth bonus, not a substitute for a proper salted slow hash — and it adds key-management complexity, so treat it as optional hardening.

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

Exercise 1

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).

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

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