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.
| Use | Not this | This |
|---|---|---|
| Passwords | md5, 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.
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.
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" };
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 thenAutumn2026!. - Offer a second factor, and rate-limit the attempts — the subject of a later lesson.