Storing Passwords Properly
Argon2id, bcrypt, and why every fast hash — including SHA-256 — is the wrong tool here.
If you accept passwords, you will one day be judged on how you stored them. There is a correct answer and it is short.
Use a password hash, not a hash
SHA-256 is designed to be fast. That is exactly wrong for passwords: a GPU computes billions of SHA-256 hashes per second, so a leaked table of them is a leaked table of passwords. A password hashing function is deliberately slow and memory-hungry.
| Algorithm | Verdict |
|---|---|
| Argon2id | ✅ current first choice |
| scrypt | ✅ good |
| bcrypt | ✅ fine; caps input at 72 bytes |
| PBKDF2 | ⚠️ acceptable where required by policy |
| SHA-256 / SHA-1 / MD5 | ❌ never, salted or not |
Parameters
For Argon2id, a reasonable starting point is 19 MiB of memory, 2 iterations and parallelism 1 — then tune so verification takes roughly 250ms on your hardware. For bcrypt, a cost of 12 and rising over time. Salting is automatic in all of these; it is part of the output string.
Rehash on login
The stored hash records the parameters it was made with. When you raise them, verify with the old ones and immediately rehash with the new — you have the plaintext at that moment and never will again.
Policy that helps
- Minimum length, not composition rules. 12+ characters beats "one uppercase, one symbol", which produces
Password1!and nothing else. - Check against known-breached passwords (the Pwned Passwords k-anonymity API sends only a hash prefix).
- Allow the full Unicode range and long passphrases, and let password managers paste.
- No forced rotation without a reason to believe there was a compromise — NIST dropped that guidance because it drives users to predictable increments.
Never
Log the password. Return it. Email it. Store it recoverably. Truncate it silently. Send it to an analytics tool by including the whole form body in an error report.
Example
import argon2 from 'argon2';
const OPTIONS = {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB
timeCost: 2,
parallelism: 1,
};
const hash = await argon2.hash(password, OPTIONS);
// $argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
// ^algorithm ^parameters ^salt ^hash
// Everything needed to verify — and to notice when the parameters are stale.
const ok = await argon2.verify(hash, password);When to use it
- A database leak exposes Argon2id hashes at 19 MiB cost, making mass offline cracking economically impractical rather than a weekend job.
- An app raises its bcrypt cost from 10 to 12 and transparently rehashes each user's password the next time they log in.
- A signup form rejects a password found in the Pwned Passwords set, stopping credential stuffing before the account exists.
More examples
Verify, rehash and check against breaches
Failing open when the breach API is unreachable is deliberate: a third-party outage must not stop people creating accounts.
import argon2 from 'argon2';
import { createHash } from 'crypto';
const OPTIONS = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 };
export async function verifyAndUpgrade(user, password) {
const ok = await argon2.verify(user.passwordHash, password).catch(() => false);
if (!ok) return false;
// We hold the plaintext exactly here and nowhere else — upgrade now or never.
if (argon2.needsRehash(user.passwordHash, OPTIONS)) {
await db.users.update(user.id, {
passwordHash: await argon2.hash(password, OPTIONS),
});
}
return true;
}
// Pwned Passwords: k-anonymity. Only the first 5 hash characters are sent, so
// the service never learns the password or even its full hash.
export async function isBreached(password) {
const sha1 = createHash('sha1').update(password).digest('hex').toUpperCase();
const prefix = sha1.slice(0, 5);
const suffix = sha1.slice(5);
const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
headers: { 'Add-Padding': 'true' }, // uniform response size
signal: AbortSignal.timeout(2000),
}).catch(() => null);
if (!res?.ok) return false; // fail open: never block signup on an outage
return (await res.text()).split('\n')
.some((line) => line.split(':')[0] === suffix);
}
export async function validatePassword(password) {
if (password.length < 12) throw new Error('Use at least 12 characters.');
if (password.length > 200) throw new Error('That is longer than we can accept.');
if (await isBreached(password)) {
throw new Error('That password has appeared in a data breach. Please choose another.');
}
}The equivalents in PHP and Python
Every mainstream language ships a correct implementation. Writing your own key-stretching loop is never the right call.
<?php
// PHP — Argon2id when the extension is available, bcrypt otherwise
$hash = password_hash($password, PASSWORD_ARGON2ID, [
'memory_cost' => 19456, // KiB
'time_cost' => 2,
'threads' => 1,
]);
if (password_verify($password, $user['password_hash'])) {
// Same rehash-on-login pattern
if (password_needs_rehash($user['password_hash'], PASSWORD_ARGON2ID, $options)) {
$new = password_hash($password, PASSWORD_ARGON2ID, $options);
updatePasswordHash($user['id'], $new);
}
}
// ❌ Every one of these is a finding in a security review
// md5($password)
// sha1($password . $salt)
// hash('sha256', $password)
// base64_encode($password) // not even a hash
# ---------------------------------------------------------------
# Python — argon2-cffi
# from argon2 import PasswordHasher
# from argon2.exceptions import VerifyMismatchError, InvalidHash
#
# ph = PasswordHasher(memory_cost=19456, time_cost=2, parallelism=1)
# hashed = ph.hash(password)
#
# try:
# ph.verify(hashed, password)
# if ph.check_needs_rehash(hashed):
# save(ph.hash(password))
# except (VerifyMismatchError, InvalidHash):
# reject()
Discussion