Password Storage
Never plaintext. Never unsalted. Never fast.
Password Storage
When a user picks a password, the only sensible thing to store is not their password. Store a one-way fingerprint instead — a hash — so that even a stolen database leaks as little as possible. The hash must be deliberately slow, deliberately unique per user, and deliberately the output of a library someone else wrote.
Analogy
A well-run hotel does not keep spare guest keys in a drawer at the front desk. If the drawer is ever stolen, the burglar walks straight into every room. Instead the hotel takes each key's unique cut-pattern — the little jagged profile — presses it into a wax tablet once at check-in, and throws the original key into the hotel's mailing slot for the guest to carry. When the guest returns and offers a key, the clerk presses it into fresh wax and compares the profile to the tablet on file. Same key? Same profile. Theft of the wax tablet alone tells the burglar nothing useful, because you can't carve a working key from a wax impression without spending forever trying every possible blank.
Three rules
- Never store plaintext. If your database leaks, every password leaks.
- Always salt. Two users with the same password must produce different stored digests.
- Deliberately slow. A fast hash helps the attacker more than you.
That's it. The rest is picking the library that enforces these for you.
Why salt
Without a salt, sha256("hunter2") is the same for every user who ever picked "hunter2". Attackers precompute a giant table once — a rainbow table — and instantly recover every matching password from any leaked database.
A random per-user salt makes every digest unique:
| User | Password | Salt | Digest |
|---|---|---|---|
| alice | hunter2 |
a1b2c3… |
8f91… |
| bob | hunter2 |
ff7701… |
2c4d… |
| carol | hunter2 |
9eab22… |
d10a… |
Same password, three different stored values. Rainbow tables are useless.
Why slow
A fast hash like SHA-256 computes billions of digests per second on a modern GPU. If an attacker steals your database and your passwords are salted but fast-hashed, they crack most 8-character passwords overnight.
A slow hash — argon2id, bcrypt, scrypt — is deliberately engineered to cost memory, CPU time, or both per guess. You tune it so one login takes ~250ms on your server. An attacker with a GPU cluster still pays roughly the same cost per guess; their throughput collapses.
The stored format
A well-formed stored password is self-describing:
$argon2id$v=19$m=65536,t=3,p=4$rWQhAqgAZqn...$lqFjPNQmCH...
└algo┘ └───params───┘ └──salt──┘ └──digest──┘
When argon2.verify(stored, candidate) runs, it reads the algorithm, reads the cost parameters, reads the salt, hashes the candidate with the same recipe, and compares the resulting digest to the stored one in constant time.
What to ship
argon2id (first choice)
bcrypt (if argon2 isn't available)
Anything else — plain sha256, plain md5, PBKDF2 with 1,000 iterations, homegrown hashing — is wrong for passwords. If your framework wants you to pick, pick argon2id.
What to never ship
- Plaintext passwords.
- Unsalted hashes.
- Fast hashes (MD5, SHA-1, SHA-256, SHA-3 on their own).
- Your own algorithm. Even with good intentions. Especially with good intentions.
- Password "encryption" — that implies reversible. Passwords aren't encrypted. They're hashed.
Checking in, from time to time
check_needs_rehash(stored) (in argon2-cffi) or equivalent lets you detect stored digests whose cost parameters are now below your current policy. When a user next logs in successfully, re-hash and store with the new parameters. Defence improves over time.
This is not encryption
Hashing is one-way. Encryption is reversible. If your boss says "encrypt the passwords," what they mean is "hash them with argon2id so nobody — including us — can ever read the plaintext again."