The sentence that starts most incidents
"We do not store passwords, we store SHA-256 hashes." I have heard that line in three post-mortems. It sounds responsible and it is not enough. When the database leaks, a modern GPU tests billions of SHA-256 candidates per second, and a table of precomputed digests turns most common passwords into plaintext before the attacker finishes reading the breach report.
Password storage has one job: make offline guessing as expensive as possible for whoever gets the hashes. Everything below serves that goal.
Why fast hashing fails
Three independent failures. Fixing one does not fix the others.
| Failure | Why it hurts | Fix |
|---|---|---|
| Speed | SHA-256 is designed to be fast; a GPU does billions per second | Slow hashing with a tunable cost |
| Determinism | Same password produces the same digest, so one rainbow table cracks everyone | Per-user salt |
| Correlation | Two users with the same password are visibly identical | Per-user salt |
Rough numbers make the first row concrete. A single RTX-class GPU can test on the order of 10^10 SHA-256 candidates per second. An eight-character password from a 72-symbol alphabet has about 10^15 combinations, which sounds safe until you divide: roughly 10^5 seconds for the full space, and far less with a dictionary-ordered search. bcrypt at cost 12 drops that to a few thousand attempts per second per core and turns the same search into centuries.
What a salt actually does
A salt is a random value generated per user, stored next to the hash, and mixed into the input before hashing. It is not secret. Its job is uniqueness: two users with the same password must produce different digests, and a rainbow table must be rebuilt per salt, which destroys the economics of precomputation.
What a salt does not do is slow down an attack on one specific account. If the attacker targets Alice and has her salt, they can still guess. That is the next layer's job.
Practical requirements: at least 16 random bytes from a CSPRNG, unique per password change. Never a global constant, never the username. Modern libraries generate and embed the salt in the output string, which is a good reason not to hand-roll this.
What slow hashing adds
bcrypt, scrypt and Argon2id take a work factor and spend real CPU time or memory. The goal is a verification cost you can afford on login and an attacker cannot afford at scale.
| Algorithm | Verdict | Notes |
|---|---|---|
| MD5 / SHA-1 | Never | Broken collisions and far too fast |
| SHA-256 (raw) | No | Correct for integrity, wrong for passwords |
| PBKDF2 | Acceptable | FIPS-friendly, weaker against GPUs than Argon2 |
| bcrypt | Recommended | Built-in salt, cost factor, 72-byte input limit |
| scrypt | Recommended | Memory-hard, parameters N/r/p |
| Argon2id | First choice | Tunable time, memory and parallelism; side-channel and GPU resistant |
Sizing the parameters
Pick a target verification time on your production hardware: 50–200 ms is the usual band. Faster feels better but weakens the defense; slower risks login timeouts under load. Then measure, do not copy a blog post.
- bcrypt: cost 12 today, incremented by one every couple of years as hardware improves. Each increment doubles the work.
- Argon2id: a common start is 64 MiB of memory, 3 iterations, parallelism 1. Memory is what makes GPU attacks expensive, so prefer raising memory over iterations.
- Store the parameters with the hash. bcrypt encodes them in the string; Argon2 needs them in the encoded format or in separate columns.
- Re-benchmark after hardware changes and raise the cost through lazy migration.
Code that does the right thing
// Node.js with bcrypt
import bcrypt from 'bcrypt';
const hash = await bcrypt.hash(password, 12);
const ok = await bcrypt.compare(password, hash);
// Argon2id, preferred when available
import argon2 from 'argon2';
const digest = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MiB
timeCost: 3,
parallelism: 1,
});
Two implementation notes. bcrypt silently truncates input beyond 72 bytes, so hash a long passphrase with SHA-256 first and feed the digest to bcrypt, or use Argon2 which has no such limit. And never compare hashes with === in application code; use the library's constant-time comparison.
Peppering, and when it helps
A pepper is a secret key mixed into the hash and stored outside the database, for example in an HSM or an environment secret. If the database leaks but the application environment does not, the attacker cannot even begin guessing. The cost is operational: rotating a pepper requires rehashing every password, and losing it locks everyone out. Use it when you can afford that operation, and treat it as defense in depth, not a replacement for salting and slow hashing.
Why not encrypt passwords
Encryption is reversible by design. If the key sits next to the ciphertext, or leaks through the same dump, every password is recovered at once. Hashing is one-way, so a leak yields only a costly guessing problem. The rare legitimate case for reversible storage is a password-equivalent secret that must be replayed to a third party, and that should live in a dedicated secret manager, not the user table.
Migrating without a flag day
- Add columns for algorithm, parameters and hash. Keep the old column during the transition.
- On login, verify with the old scheme. On success, immediately rehash with the new one and write both the hash and the algorithm marker.
- Do not force a mass reset. Lazy migration clears active users within weeks; dormant accounts can be reset on next login or after a deadline.
- Track the migration ratio and drop the old column only when it is near zero.
Operational details people forget
- Rate limit and lock progressively. Slow hashing protects offline attacks; online guessing needs throttling per account and per IP.
- Never log passwords. Check request logging, error reporting and analytics payloads. The leak is usually a debug line, not the database.
- Reset tokens must be short and single-use. 15–30 minutes, invalidated on use, and hashed at rest like a password.
- Do not reimplement the algorithm. Use a maintained library and follow its recommended parameters.
When the breach happens
Assume it will. Have a runbook: rotate database credentials, invalidate sessions and refresh tokens, force a password reset for affected accounts, and publish a clear notice. Because you stored salted slow hashes, you can say precisely what was and was not exposed. If you stored plaintext or fast hashes, the honest answer is that every password must be treated as compromised, and users must be told to change it wherever it was reused.
Keep the old hashes long enough to detect credential stuffing, but not forever. A common pattern is retaining a flag that a password was reset after a breach, so login can prompt an upgrade without storing anything reversible.
The takeaway
Store passwords with a per-user salt and a slow, memory-hard hash sized to 50–200 ms on your hardware, with the parameters stored alongside the digest so you can upgrade later. A salt defeats precomputation, slow hashing defeats raw speed, and neither replaces rate limiting and careful logging.
Try it: Hash calculator, Password generator.