Password storage, correctly — the algorithm, the attack, the answer.
Storing passwords incorrectly is the gift that keeps on giving — to attackers. Every year, more breaches expose plain-text, MD5'd, or SHA-256'd passwords that get cracked in hours. The fix has been known for 25 years, and is one of the most documented topics in computer security. Yet the wrong patterns persist. This guide is the working reference: the algorithms that work in 2026, the parameters that matter, and the attacks they defend against.
01What hashing actually solves
The basic principle: you should never know the user's password. You only know whether what they typed today matches what they typed when they signed up. The mechanism is a one-way function: hash the password on signup, store the hash. On login, hash what they type, compare to the stored hash. The plain text never appears in your database, your logs, or your memory after the request.
This protects users when (not if) your database leaks. Attackers get hashes, not passwords. Whether they can recover the passwords from those hashes depends entirely on which algorithm you used.
02The algorithms to never use
- Plain text. Obvious, still happens.
- MD5, SHA-1. Broken cryptographically. Easy to find collisions.
- SHA-256, SHA-512 (alone). Not broken, but designed to be fast. A modern GPU computes 10 billion SHA-256 hashes per second. An 8-character password falls in minutes.
- Encryption. Encryption is reversible by design. A stolen key recovers every password. Use hashing.
- Custom schemes. If you invented it, it's broken. There's no exception to this.
03The algorithms to use
Two acceptable choices in 2026, in order of preference:
- Argon2id. Winner of the 2015 Password Hashing Competition. Resistant to GPU and ASIC attacks. The current best choice.
- bcrypt. Older but battle-tested since 1999. Still considered secure with appropriate work factor. Use if your platform doesn't have a good Argon2 library.
Both are intentionally slow. That's the point. A login should take 100-500 milliseconds. A brute-force attack on a stolen database becomes computationally infeasible.
scrypt and PBKDF2 are also acceptable but neither is preferred over Argon2id in 2026.
04Argon2id — the recommended parameters
Argon2 has three tunable parameters:
- Memory cost — how much RAM the hash uses. Higher = harder to parallelize on GPUs.
- Time cost — number of iterations. Higher = slower for both you and attackers.
- Parallelism — number of threads. Usually set to 1 for single-server use.
OWASP-recommended parameters for 2026:
import argon2 from 'argon2';
// On signup
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB
timeCost: 2,
parallelism: 1
});
// On login
const valid = await argon2.verify(hash, candidatePassword);
The library handles salt generation automatically and encodes the parameters into the hash string itself, so verification reads them back correctly. Don't try to remember salts separately.
05bcrypt — the cost factor
bcrypt's one parameter is the cost factor — the log2 of the number of iterations. Each increment doubles the time.
import bcrypt from 'bcrypt';
// On signup
const hash = await bcrypt.hash(password, 12);
// On login
const valid = await bcrypt.compare(candidatePassword, hash);
Cost 12 is the 2026 minimum (takes ~250ms on a modern CPU). Don't go below 10. Don't go above 14 unless your hardware can sustain it.
06Salt — already handled, don't touch it
A salt is a random value added to the password before hashing. Two users with identical passwords get different hashes because of different salts. This defeats rainbow tables (precomputed hash dictionaries).
Both Argon2 and bcrypt generate and store salts automatically. You don't need to manage salts yourself — and trying to is a common source of bugs. The library handles it. Trust the library.
07Pepper — an extra defense layer
A pepper is a secret value combined with the password before hashing, but unlike salt, it's NOT stored in the database. It lives in your application config (environment variable, secret manager).
import crypto from 'crypto';
function pepperize(password: string): string {
return crypto
.createHmac('sha256', process.env.PEPPER!)
.update(password)
.digest('hex');
}
// Then hash the peppered value with argon2
const hash = await argon2.hash(pepperize(password));
Why? If your database leaks but your application secrets don't, attackers can't even start cracking the hashes — they don't know the pepper. This adds a meaningful defense layer at almost no cost.
Caveat: rotating the pepper is hard. You can't re-derive old hashes. Common pattern: support both old and new pepper, and re-hash on next successful login.
08Timing attacks — the comparison trap
Naive string comparison returns false at the first mismatched byte. This leaks information: attackers can measure response time and learn how many leading bytes match.
Both Argon2 and bcrypt libraries use constant-time comparison internally. Their verify() functions are safe. But if you ever compare hashes manually (verifying API keys, session tokens), use constant-time comparison:
import { timingSafeEqual } from 'crypto';
function verifyToken(provided: string, expected: string): boolean {
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
09Rotation — upgrading parameters over time
CPU speeds increase, attacks improve. The parameters that were strong in 2020 are weak in 2026. You need a strategy for upgrading hashes without forcing every user to reset their password.
The pattern: on successful login, check if the hash uses outdated parameters. If so, re-hash with current parameters and update the stored hash.
async function login(email: string, password: string) {
const user = await db.getUserByEmail(email);
if (!user) throw new Error('Invalid credentials');
const valid = await argon2.verify(user.passwordHash, password);
if (!valid) throw new Error('Invalid credentials');
// Check if hash needs upgrade
if (argon2.needsRehash(user.passwordHash, CURRENT_PARAMS)) {
const newHash = await argon2.hash(password, CURRENT_PARAMS);
await db.updatePasswordHash(user.id, newHash);
}
return issueSession(user);
}
10The other parts of auth security
Password hashing is just one piece. The complete picture also includes:
- Rate limiting on login attempts. Even unbreakable hashes don't protect against credential stuffing if you allow 1000 login attempts per second. Throttle aggressively.
- HaveIBeenPwned check. On signup, check the password against the HIBP database. Reject the top 100,000 most common passwords. Free API, takes 50ms.
- 2FA / TOTP. The single highest-leverage security improvement after password hashing. Offer it. Encourage it. Require it for high-value accounts.
- Session management. Hashed passwords don't help if sessions are stored insecurely. Use HTTP-only, Secure, SameSite=Strict cookies.
- Forgot-password flow. Tokenized links sent to email, single-use, time-bounded (15 minutes max). Never email passwords.
∞The non-negotiable
Password storage is one of those engineering decisions where there's no excuse for getting it wrong. The libraries exist. The parameters are documented. OWASP publishes the recommendations in a single page. The cost is one library install and twenty lines of code.
If your application stores passwords with anything other than Argon2id or bcrypt with sane parameters, fix it this week. Not next sprint. This week. The breach happens on its own schedule, not yours.