Password storage
Why a fast hash is the wrong tool, what salt and a work factor each buy, and the bcrypt string that carries both inside it.
You are not storing passwords. You are storing something that lets you check a password without being able to recover it — and the difference is what decides whether a stolen database is an incident or a catastrophe.
The rules are settled, they are short, and nearly every way of getting this wrong comes from applying general-purpose engineering instincts to a problem where they are backwards.
A fast hash is the wrong tool, and here is why
The instinct is: hash it, store the hash, compare hashes. The hash is one-way, so the password cannot be recovered. Correct — and insufficient:
### sha256 #1: f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7
### sha256 #2: f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7The same password produces the same hash, every time, on every machine. Two consequences follow, and they are the whole problem:
- Anyone with the stolen table can see which users share a password, without cracking anything.
- The attacker does not need to reverse the hash. They hash a list of common passwords once and look yours up. The work is done in advance and reused against every database in the world.
And SHA-256 is fast — that is what it was designed for. Fast is exactly wrong here: a modern GPU computes billions of them per second, so "guess and check" is cheap at a scale that makes most human-chosen passwords findable.
Salt makes each hash unique; a slow algorithm makes each guess expensive
A salt is a random value stored alongside the hash and mixed in before hashing. Now the same password produces a different hash for every user, so a precomputed table is useless and identical passwords are no longer visible.
A work factor makes the function deliberately slow, and adjustable, so it can be made slower as hardware gets faster.
BCryptPasswordEncoder does both, and the salt handling is the part worth seeing, because it surprises people:
### bcrypt #1: $2a$10$nFGmQBrMGQjDbSCALpjwG.IlGuqnDTnO3.be3NnMlTyMDuRTetdYO
### bcrypt #2: $2a$10$zLVHB530DuJyMSSbezUfa.nrfZMvKfT4zzeFqjTWOEtdIFiyYD0T6
### equal as strings? false
### does #1 verify the password? true
### does #2 verify the password? true
### does #1 verify a wrong one? falseThe same password, hashed twice, produced two completely different strings — and both verify it. That is not magic. Read the format:
$2a$ 10 $ nFGmQBrMGQjDbSCALpjwG. IlGuqnDTnO3.be3NnMlTyMDuRTetdYO
│ │ └── the 22-char salt └── the hash
│ └── work factor: 2^10 iterations
└── the bcrypt variantThe salt is inside the stored string. There is no separate salt column to design, no salt to manage, and no way to forget to store it. matches() reads the salt and the cost out of the stored value, applies them to the candidate password, and compares.
What the work factor costs, measured
Each increment doubles the work. On this machine:
### cost 4: 1.2 ms
### cost 8: 17.2 ms
### cost 10: 68.2 ms
### cost 12: 273.6 msThe number to choose is not a constant from a blog post; it is whatever takes about 100 ms on your production hardware, measured there. Slow enough that offline guessing is expensive, fast enough that logging in feels instant and a burst of logins does not exhaust your threads.
That last clause is a real operational consideration: at cost 12, a hundred concurrent logins is 27 seconds of pure CPU. Password verification is the one place in a web application where you are deliberately burning CPU, and it wants to be sized like any other capacity decision.
Because the cost is stored in the hash, you can raise it later: on the next successful login, re-encode with the new factor and update the row. Old hashes keep working; new ones are stronger.
Which algorithm
| Use it | |
|---|---|
| bcrypt | the safe default; everywhere, battle-tested, in Spring Security already |
| Argon2id | the current recommendation where you can choose; resists GPU and memory-hard attacks |
| scrypt | fine; memory-hard, less common in Java |
| PBKDF2 | acceptable, and often mandated by compliance; weakest of the four against GPUs |
| never, alone, for passwords |
bcrypt has one quirk worth knowing: it truncates at 72 bytes. A longer passphrase is silently cut, which matters if you accept long ones. Argon2id does not have this limit.
Spring Security's DelegatingPasswordEncoder is what makes migration possible. It stores the algorithm as a prefix — {bcrypt}$2a$10$... — so a single column can hold hashes made by different algorithms, and you can move to Argon2id by encoding new and re-encoding on login.
The rest of the login path
Hashing correctly and then leaking the answer elsewhere is common enough to be worth listing:
- Never log the password. Not at debug, not in a request dump, not in an exception message. A
toString()on a request DTO that includes a password field will eventually end up in a log line. - Compare in constant time.
encoder.matches()does this; a hand-writtenhash.equals(stored)on a raw hash does not, and a timing difference is measurable over a network. - Say the same thing for both failures. "No such user" and "wrong password" are different answers, and returning different ones tells an attacker which email addresses are registered — including on the registration and password-reset paths, where the leak is easiest to forget.
- Rate-limit login attempts per account and per IP. Hashing is slow by design, which also means an unlimited login endpoint is a cheap denial-of-service against your own CPU.
- Do not impose rules that make passwords worse. Length is what matters; forced rotation, mandatory symbols and a 16-character maximum all push people towards
Password1!and a sticky note. Check the candidate against a list of known-breached passwords instead.