Hashing & Integrity
Password Hashing
bcrypt, scrypt, and Argon2: why password storage is its own problem.
Storing passwords is the one place where a fast, “good” hash is exactly the wrong tool. When a database leaks (and databases leak), the attacker gets your stored password values and can guess offline at enormous speed. The defense is to store passwords with functions that are deliberately slow and memory-hungry, plus a unique salt each. Argon2, scrypt, and bcrypt exist precisely for this, and using SHA-256 here is a serious, common vulnerability.
Why fast hashes fail for passwords: GPU cracking
SHA-256 was designed to be fast, and that speed helps the attacker far more than it helps you. A defender checks one password per login; an attacker with a leaked hash database checks billions per second. Modern GPUs compute plain SHA-256 at well over ten billion guesses per second per card, and rigs of them go far higher.
That speed makes several cheap attacks devastating:
- Brute force and dictionary attacks: most human passwords fall to wordlists and mutation rules in minutes when the hash is fast.
- Rainbow tables: precomputed hash-to-password lookups let an attacker reverse unsalted fast hashes almost instantly.
- Credential stuffing amplification: cracked passwords get replayed across other sites.
The property that stops this is being slow and hard to parallelize. Password hashes are engineered so that a single check is cheap enough for a login (tens of milliseconds) but multiplying that by billions of guesses is economically infeasible for the attacker. This is the opposite goal from Hash Functions, where speed is a feature.
Salts, work factors, and memory hardness
Three ingredients turn a slow hash into safe password storage:
- Salt: a unique random value stored alongside each hash. It ensures two users with the same password get different stored values, defeating rainbow tables and stopping an attacker from cracking many accounts at once. A salt is not secret; it just needs to be unique per password. Modern password hashes generate and embed the salt for you.
- Work factor (cost): a tunable number of iterations that sets how expensive one hash is. You raise it over the years as hardware gets faster, keeping a login around tens of milliseconds.
- Memory hardness: requiring a large amount of RAM per hash. GPUs and ASICs have massive compute but comparatively limited fast memory, so a memory-hard function neutralizes their parallelism advantage. This is the key innovation of scrypt and Argon2 that bcrypt lacks.
| Algorithm | Year | Tunable cost | Memory-hard | Notes |
|---|---|---|---|---|
| bcrypt | 1999 | cost factor | no (fixed 4 KB) | Solid, ubiquitous, but capped at 72 bytes input and not memory-hard |
| scrypt | 2009 | N, r, p | yes | Memory-hard, good where Argon2 is unavailable |
| Argon2id | 2015 | time, memory, parallelism | yes | Password Hashing Competition winner, current default |
| SHA-256 | 2001 | none | no | Never use alone for passwords |
Argon2id parameters and migration strategies
Argon2 is the current recommended default, and the id variant blends resistance to both GPU and side-channel attacks. It takes three tunable parameters:
- Memory cost (
m): how much RAM per hash, for example 64 MiB or more. This is the main defense against parallel hardware. - Time cost (
t): the number of iterations, for example 2 to 3. - Parallelism (
p): the number of lanes, often matched to available cores.
A reasonable starting point for an interactive login on a server is roughly 64 MiB memory, time cost 3, parallelism 1 to 4, then tune so a single hash takes about 50 to 250 milliseconds on your hardware. OWASP publishes current baseline parameters; revisit them periodically.
You can experiment with the memory-hard scrypt KDF directly through OpenSSL to feel the cost knobs (Argon2 is best used through libsodium or a language library):
mkdir -p ~/crypto-lab && cd ~/crypto-lab
SALT=$(openssl rand -hex 16)
openssl kdf -keylen 32 -kdfopt digest:SHA256 \
-kdfopt pass:'correct horse battery staple' \
-kdfopt hexsalt:$SALT \
-kdfopt n:16384 -kdfopt r:8 -kdfopt p:1 SCRYPT
Expected output:
9F:3C:2A:...:B1 (32 raw bytes shown as colon-separated hex)
Raising n (the scrypt cost) makes each derivation noticeably slower and hungrier for memory, which is exactly the point.
Migration strategy matters because you cannot decrypt stored password hashes to re-hash them; you only have the hash. The standard approaches:
- Rehash on next login: when a user authenticates successfully and their stored hash uses old parameters (or an old algorithm), recompute with the new settings and store that. Over time active accounts migrate.
- Wrap the old hash: for a hard cutover, store
newAlgorithm(oldHash)immediately for every row, then unwrap and rehash on login. This upgrades even dormant accounts at once. - Track the algorithm and parameters in the stored string. Argon2 and bcrypt encode their parameters in the hash output (for example
$argon2id$v=19$m=65536,t=3,p=4$...), so verification code reads them automatically and you can detect out-of-date entries.
Practical Guidance
- Never store passwords with a plain or fast hash (SHA-256, MD5) or with encryption. Use Argon2id, scrypt, or bcrypt.
- Prefer Argon2id for new systems; use scrypt where Argon2 is unavailable, and bcrypt for legacy compatibility.
- Use a unique random salt per password. Modern password hashes generate and embed it, so let the library handle it rather than reusing one salt.
- Tune parameters so a single hash takes about 50 to 250 ms on your hardware, and raise the cost over time as hardware improves.
- Rehash on successful login when stored parameters are outdated, and store the algorithm and parameters with each hash so you can detect stragglers.
- Verify with the library’s constant-time comparison and let it read parameters from the stored hash. Follow rule zero and never assemble your own password hasher.