Passwords

Store a password so that a stolen database is not a list of accounts, and log in without telling an attacker who exists.

05of 16
≈ 13Min read
5Parts

A hash is not encryption

Encryption is reversible: whoever holds the key gets the password back, and the key is on the same server. A password hash is one-way — you never need the original, only the ability to check a guess.

And not any hash. SHA-256 is designed to be fast, which is what an attacker wants: a modern GPU tries billions of SHA-256 guesses a second. A password hash is designed to be slow and memory-hungry, so a guess costs real time and real RAM.

UseNot thisThis
Passwordsmd5, sha1, sha256, "encrypted"argon2id, scrypt, bcrypt
API keys and tokens (high entropy)—sha256 is fine — there is nothing to guess
Comparing secrets===a constant-time compare

Salt, and why it is not a secret

A salt makes two users with the same password have different hashes, which kills precomputed tables and stops an attacker cracking one hash and getting a hundred accounts. It does not need to be secret — it needs to be unique and random.

A pepper is a secret added to every password, kept outside the database (in an environment variable or a KMS). It helps only against an attacker who has the database and not the application — which is a real and common case, so it is worth the ten lines.

unique per user, stored beside the hash
const salt = crypto.randomBytes(16);
const hash = await argon2.hash(password, { salt, type: argon2.argon2id });

// stored: $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>
// the parameters travel with it, so they can be raised later

Choosing the cost

Every algorithm has parameters that decide how slow it is: memory, iterations, parallelism. Turn them up until a single hash takes around 250–500 ms on your production hardware, then measure again next year. Too low and an attacker cracks the file; too high and your login endpoint becomes a way to exhaust your own CPU.

Store the parameters with the hash (every modern format does), so that raising them later is a background upgrade: when a user logs in with an old hash, verify it, then re-hash at the new cost and save.

The login that does not leak

"No account with that email" is an account-enumeration oracle: an attacker feeds a list of addresses to your login form and learns which ones are customers. So is answering in 5 ms for an unknown user and 300 ms for a real one — the timing alone is the answer.

The same rule applies to registration ("that email is taken"), to password reset ("we have sent you an email" — always, whether or not the account exists), and to any endpoint that can be asked about a person.

two failures, one answer
const user = await findUser(email);

// always do the work, even when the user does not exist
const ok = user
  ? await verify(user.hash, password)
  : await verify(DUMMY_HASH, password);      // same cost, same timing

if (!user || !ok) return { status: 401, message: "Email or password is wrong" };
Compare in constant time

A normal === on strings stops at the first differing character, so the time it takes reveals how much of a token was right. For tokens and MACs, use crypto.timingSafeEqual. For a password hash it matters less — the hash is the comparison — but for the reset tokens in the next lesson it matters completely.

What to require of a password

  • A minimum length of 12, and nothing else. Composition rules ("one capital, one symbol") push people towards Password1! and into reuse.
  • Check it against a breach list (Have I Been Pwned's k-anonymity API sends only five characters of the hash). One known-breached password is worth more than every composition rule combined.
  • Allow everything — spaces, emoji, 200 characters. A truncation at 16 is a bug and bcrypt's 72-byte limit is a real one to know about.
  • Do not expire passwords on a schedule. NIST dropped that advice: forced rotation produces Summer2026! and then Autumn2026!.
  • Offer a second factor, and rate-limit the attempts — the subject of a later lesson.

Practice