What you'll learn
- Explain salted hashing
- Explain why encryption and fast hashes are the wrong tools for passwords
- Choose a slow, adaptive algorithm such as Argon2id, scrypt or bcrypt
- Combine hashing with rate limits, breached-password checks and 2FA
Explain why passwords are stored as salted, slow hashes and how to do it with a maintained library.
Why it matters
Databases get leaked: through a SQL injection bug, a misconfigured backup, a stolen laptop or an insider. When that happens, the question is whether the attacker also gets every user's password. People reuse passwords, so a leaked password from your site can unlock their email or bank. Storing passwords correctly means that even with a full copy of your database, an attacker has to do enormous work to recover them. It is a small amount of code with a very large effect.
Encryption is the wrong tool
Encryption is reversible: anyone with the key gets the original back. If the key sits on the same server as the database (it usually does), a leak of one is likely a leak of both. You never need to read a user's password back, only to check that a login attempt matches. That is a job for a hash.
Hashing
A cryptographic hash functionA one-way function that turns any input into a fixed-size digest; the same input always gives the same digest. turns any input into a fixed-size output (a digest). It is one-way (you cannot compute the input from the digest) and the same input always gives the same digest:
sha256("hunter2") -> f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7At login, you hash the attempt and compare digests. So far so good, but plain fast hashes such as MD5 or SHA-256 are not enough for passwords, for two reasons.
1. Same password, same hash. If a thousand users chose password123, they all have the same digest. An attacker cracks one and gets a thousand accounts. Worse, attackers keep huge precomputed tables of digests for common passwords (rainbow tablesHuge precomputed tables of hashes for common passwords, which salting makes useless.), so a plain SHA-256 of a common password can be looked up instantly.
2. Fast is bad here. SHA-256 is designed to be fast. A single modern graphics card can compute billions of SHA-256 hashes per second, so guessing every likely password takes very little time.
Salt: make every hash unique
A saltA random value generated per user and combined with the password before hashing; it is stored, not secret. is a random value, generated fresh for each user, that is combined with the password before hashing:
user salt stored hash
ada 9f1c...e2 (random) hash("9f1c...e2" + "password123") -> 4b7a...
bob 03aa...71 (random) hash("03aa...71" + "password123") -> d91e...Now two users with the same password have different hashes, and precomputed tables are useless because the attacker would need a table per salt. The salt is not secret; it is stored next to the hash. Its job is uniqueness, not secrecy.
Slow, adaptive hashing
The second fix is to use a hash designed to be deliberately slow and tunable, so that one check takes tens to hundreds of milliseconds. A user logging in does not notice; an attacker trying billions of guesses does. These are called password hashing functions or key derivation functions:
| Algorithm | Notes |
|---|---|
| Argon2id | Current first choice in the OWASP Password Storage Cheat Sheet; memory-hard, which makes GPU attacks costly |
| scrypt | Memory-hard; built into Node as crypto.scrypt |
| bcrypt | Older, still widely used and acceptable; has a work factor ("cost") and a 72-byte input limit |
| PBKDF2 | Acceptable with a high iteration count; required by some compliance rules |
Each has a cost parameterA setting on a password hashing function that controls how slow each hash is, raised as hardware gets faster.. Raise it over the years as hardware gets faster. Good libraries generate the salt for you and store the algorithm, cost and salt inside the output string, for example $2b$12$... for bcrypt, so you can upgrade later.
Worked example in Node
With the bcrypt package (or bcryptjs, a pure JavaScript version):
import bcrypt from "bcrypt";
// Signup: hash once, store only the hash.
const passwordHash = await bcrypt.hash(password, 12); // 12 = cost factor
await db.users.insert({ email, passwordHash });
// Login: compare the attempt with the stored hash.
const user = await db.users.findByEmail(email);
const ok = user ? await bcrypt.compare(attempt, user.passwordHash) : false;
if (!ok) return res.status(401).json({ error: "Invalid email or password." });Notice that compare reads the salt and cost from the stored string, so you never handle the salt yourself. Notice also the single error message: the response does not say whether the email exists. Careful implementations even run a dummy compare when the email is unknown, so the response time does not give it away either.
Around the hash
Hashing is one layer. The rest of a sound password system:
- Rate-limit and lock out repeated failed logins, so online guessing is slow.
- Set a sensible minimum length (NIST guidance suggests at least 8, preferably more) and allow long passphrases. Check new passwords against lists of known-breached passwords instead of forcing odd character rules.
- Offer two-factor authentication, so a stolen password alone is not enough.
- Never log passwords, including in request-body logs and error reports.
- A pepperAn optional extra secret key kept outside the database, mixed into password hashing to help if only the database leaks. (an extra secret key kept outside the database, for example in an environment variable) adds protection if only the database leaks. It is optional and must be managed like any other secret.
Common mistakes
- Storing passwords in plain text or with reversible encryption.
- Using a fast hash (MD5, SHA-1, SHA-256) without a password hashing function.
- One salt for every user, or a salt derived from the username.
- Writing your own scheme instead of using a maintained library.
- Different error messages for "unknown email" and "wrong password".
Quick recap
- Never store passwords or encrypt them; store a salted, slow hash.
- A salt is random per user and makes identical passwords hash differently. It is stored, not secret.
- Use Argon2id, scrypt or bcrypt with a cost you raise over time.
- Combine hashing with rate limits, breached-password checks, 2FA and clean logs.
The Do tab quiz checks that you can explain each piece in your own words.
Key terms (5)
- cryptographic hash function
- A one-way function that turns any input into a fixed-size digest; the same input always gives the same digest.
- salt
- A random value generated per user and combined with the password before hashing; it is stored, not secret.
- rainbow tables
- Huge precomputed tables of hashes for common passwords, which salting makes useless.
- cost parameter
- A setting on a password hashing function that controls how slow each hash is, raised as hardware gets faster.
- pepper
- An optional extra secret key kept outside the database, mixed into password hashing to help if only the database leaks.