Warming up the neural circuits...
By the end of this chapter you will:
Never store passwords. Ever. Store a hash that can be verified but never reversed. If your database leaks, the attacker gets hashes — not passwords. That's the difference between a bad day (rotate passwords) and a catastrophic day (every user's credentials exposed on the dark web).
Put a strawberry in a blender and hit "blend." You get strawberry purée. You cannot un-blend purée back into a strawberry. Hashing is the same: "hunter2" → bcrypt → $2b$10$... — irreversible. When a user logs in, you blend their the same way and compare the results. If they match, the input was the same. You never know (or store) the original password.
General-purpose hash functions (MD5, SHA-1, SHA-256) are designed to be fast. A modern GPU can compute billions of SHA-256 hashes per second. This is great for verifying file integrity. It's catastrophic for passwords — an attacker can try billions of passwords per second.
Password hashing functions (bcrypt, argon2, scrypt) are designed to be slow and memory-hard. You configure the "cost" so a single hash takes ~300ms on your server. That's unnoticeable for a single login, but it limits an attacker to ~3 attempts per second per GPU — 100 million times slower.
| Algorithm | Speed (1 hash) | GPU attacks/sec | Memory-hard? | Verdict |
|---|---|---|---|---|
| MD5 | ~0.0001ms | Billions | ❌ | Never for passwords |
| SHA-256 | ~0.001ms | Billions | ❌ | Never for passwords |
| bcrypt | ~300ms (cost 12) | ~3/sec/GPU | ✅ (small) | ✅ Good |
| argon2id | ~300ms (tuned) | < 1/sec/GPU | ✅ (large) | ✅ Best (2025 recommendation) |
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 12; // 2^12 iterations. Higher = slower = more secure.
// Hash a password
async function hashPassword(password) {
const salt = await bcrypt.genSalt(SALT_ROUNDS
Notice that bcrypt doesn't require you to store a separate salt. The salt is embedded in the output string: $2b$12$<22-char-salt><31-char-hash>. This is intentional — it means every password gets a unique salt automatically, and verification just needs the hash string.
The cost factor determines how many rounds of hashing are performed. Each increment doubles the work.
cost = 10 → ~75ms
cost = 11 → ~150ms
cost = 12 → ~300ms
cost = 13 → ~600ms
cost = 14 → ~1.2s// Test different costs and pick one that takes ~300ms on your hardware
async function tuneCost() {
for (let cost = 10; cost <= 14; cost++) {
const start = Date.now();
await bcrypt.hash('
Rule: Pick the highest cost that keeps login under 500ms for your users. This is the slowest your server can tolerate and the fastest an attacker must endure.
Argon2 won the Password Hashing Competition in 2015. It's memory-hard in addition to CPU-hard, which makes GPU attacks nearly impossible.
Argon2 has three variants:
const argon2 = require('argon2');
async function hashPassword(password) {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB memory usage
You're using bcrypt. You want to move to argon2id. You have 1 million users. You cannot ask them all to log in at once. Solution: re-hash on login.
async function verifyAndUpgrade(password, storedHash) {
// Check what algorithm the stored hash uses
if (storedHash.startsWith('$2b$')) {
// Old bcrypt hash
const valid = await bcrypt.compare(password,
Over time, active users get upgraded automatically. Users who never log in stay on bcrypt — which is fine. When they eventually log in, they get upgraded. No downtime, no batch job, no user communication needed.
A pepper is a secret value added to the password before hashing, stored separately from the database (in environment variables or a secrets manager).
// The pepper is stored in the application config, NOT in the database
const PEPPER = process.env.PASSWORD_PEPPER; // 32+ random bytes, base64 encoded
async function hashPassword(password) {
const peppered = password + PEPPER; // Add pepper before hashing
Why pepper matters: If an attacker steals your database ( injection, backup leak), they have the hashed passwords. Without the pepper, they can attempt to crack them offline. With a pepper, even if they have the database, they cannot crack a single password without also compromising your application server or environment variables. Defense in depth.
Pepper is an additional layer, not a replacement. If an attacker gets both the database AND your application server, they have the pepper. This is why you still need bcrypt/argon2 with a high cost factor — the hash algorithm is the primary defense. Pepper is the backup.
Adobe stored 153 million user passwords encrypted (not hashed — reversible) with 3DES in ECB mode. ECB mode means identical plaintext produces identical ciphertext. The result: anyone with the leaked database could see that 1.9 million users had the password "123456" (identical encrypted output) and reverse-engineer the encryption key from known passwords.
What they should have done: Salted bcrypt hashing. Every password would have a unique salt, making identical passwords produce different hashes. The cost factor would slow cracking to ~3 attempts per second. 153 million individually salted bcrypt hashes at cost 12 would take centuries to crack — even with a data center full of GPUs.
| Mistake | Why it's wrong | What to do instead |
|---|---|---|
| Storing plaintext passwords | Database leak = every account compromised instantly | Always hash with bcrypt or argon2id |
| Using MD5/SHA-256 for passwords | Billions of guesses per second on a GPU | Use bcrypt (CPU-hard) or argon2id (CPU + memory-hard) |
| Same salt for every password | Identical passwords produce identical hashes. Rainbow table attacks become viable. | Use a unique, random salt per password. Bcrypt and argon2 do this automatically. |
| Cost factor too low (bcrypt cost=5) | ~3ms per hash. An attacker can try thousands per second. | Tune to ~300ms per hash on your production hardware. |
| No pepper | Database-only attack is enough to start cracking passwords | Add a 32+ byte random pepper stored outside the database |
| Truncating passwords before hashing | "Maximum password length: 16 characters" — you've reduced the search space to 16 chars | Never truncate. bcrypt uses the first 72 bytes. argon2 has no practical limit. If needed, pre-hash with SHA-256 before bcrypt: bcrypt(sha256(password)) |
| Sending password in error messages | "Password is incorrect" vs "User not found" — the first confirms the user exists (enumeration) | Return identical error messages: "Invalid email or password" |
password before it hits your logging pipeline.bcryptjs (pure JS, non-blocking if you wrap it) or run hashing in a worker thread for high-throughput login endpoints.argon2 npm package. It's the better choice for throughput.bcrypt.compare and argon2.verify use constant-time string comparison internally to prevent timing attacks. Never use === to compare hashes.pepper_version alongside each hash. When a user logs in with an old pepper, verify, then re-hash with the new pepper. This enables seamless pepper rotation without forcing password resets.Why can't you just store passwords in the database? If the database is compromised (SQL injection, backup leak, insider threat), every password is exposed. Attackers can use those passwords on other sites (credential stuffing). Hashing makes the database useless to an attacker without also compromising the application.
What does a salt do? A random value added to each password before hashing, ensuring that identical passwords produce different hashes. Without salt, two users with password "hunter2" would have identical hashes, and attackers could use pre-computed rainbow tables.
Why is bcrypt better than SHA-256 for passwords? Bcrypt is designed to be slow and CPU-intensive (~300ms per hash). SHA-256 is designed to be fast (~0.001ms). An attacker can try billions of SHA-256 hashes per second vs ~3 bcrypt hashes per second.
How do you migrate from bcrypt to argon2id without downtime? Re-hash on login. Store both algorithms in production. When a user logs in with a bcrypt hash, verify with bcrypt, then immediately re-hash with argon2id and update the stored hash. Over time, active users migrate automatically. Inactive users stay on bcrypt until their next login. No batch job, no forced password reset.
What's the difference between a salt and a pepper? A salt is unique per password and stored alongside the hash (often embedded in the hash string). It prevents rainbow table attacks and ensures identical passwords produce different hashes. A pepper is a single secret value shared across all passwords, stored outside the database (environment variables, secrets manager). It ensures that a database-only breach is insufficient for cracking passwords.
How would you detect that a password hash has been cracked? You can't — that's the point of hashing. Detection relies on external signals: unusual login patterns from accounts that haven't logged in for years, login attempts from new IPs with correct passwords (credential stuffing), or monitoring dark web dumps for your domain's emails. Proactive defense: use strong algorithms, high cost factors, pepper, and offer multi-factor authentication.
Never store passwords. Hash them with bcrypt (good) or argon2id (best). Use a unique salt per password. Add a pepper stored outside the database. Tune the cost factor to ~300ms on your hardware. Migrate hash algorithms on login — users upgrade automatically. Rate limit login attempts. Never log passwords. Return identical error messages for "user not found" and "wrong password."