Storing user passwords in the database as plaintext or with MD5/SHA256 is an unacceptable mistake in 2026. The industry standard for modern password hashing is three algorithms: argon2id (best for new projects), bcrypt (venerable and secure), scrypt (a solid alternative). This article compares all three with practical code.
Why Not MD5/SHA256?
MD5 and SHA256 are fast hash functions — billions of computations per second. A GPU can do 100+ billion SHA256s per second. An attacker can brute-force an 8-character password in hours. Password hashing functions are deliberately slow.
The Three Algorithms
bcrypt
// Node.js
import bcrypt from 'bcrypt';
// On sign-up
const saltRounds = 12; // 2^12 = 4096 iterations
const hash = await bcrypt.hash(plainPassword, saltRounds);
// hash = '$2b$12$...' — self-describing
// On login
const isValid = await bcrypt.compare(plainPassword, hashFromDb);
// Update the work factor
if (bcrypt.getRounds(hashFromDb) < 12) {
const newHash = await bcrypt.hash(plainPassword, 12);
await db.users.update({ id }, { password: newHash });
}
argon2id (Recommended)
Winner of the 2015 Password Hashing Competition. Three variants: argon2d (GPU-resistant), argon2i (side-channel-resistant), argon2id (blend of both, the modern default).
// Node.js
import argon2 from 'argon2';
// Sign-up
const hash = await argon2.hash(plainPassword, {
type: argon2.argon2id,
memoryCost: 19456, // KiB (19 MiB)
timeCost: 2, // iterations
parallelism: 1
});
// hash = '$argon2id$v=19$m=19456,t=2,p=1$...'
// Login
const isValid = await argon2.verify(hashFromDb, plainPassword);
// Updating the work factor (rehash when needed)
if (argon2.needsRehash(hashFromDb, { memoryCost: 19456, timeCost: 2 })) {
const newHash = await argon2.hash(plainPassword, { ... });
await db.users.update({ id }, { password: newHash });
}
# Python
from argon2 import PasswordHasher
ph = PasswordHasher(memory_cost=19456, time_cost=2, parallelism=1)
hash = ph.hash(password)
try:
ph.verify(hash, password)
except argon2.exceptions.VerifyMismatchError:
# wrong password
pass
if ph.check_needs_rehash(hash):
new_hash = ph.hash(password)
scrypt
// Node.js built-in (crypto module)
import { scrypt, randomBytes } from 'crypto';
import { promisify } from 'util';
const scryptAsync = promisify(scrypt);
// Sign-up
const salt = randomBytes(16).toString('hex');
const derivedKey = await scryptAsync(plainPassword, salt, 64, {
N: 32768, // 2^15 — iterations
r: 8,
p: 1
});
const hash = `${salt}:${derivedKey.toString('hex')}`;
// Login
const [salt, key] = hashFromDb.split(':');
const derivedKey = await scryptAsync(plainPassword, salt, 64, { N: 32768, r: 8, p: 1 });
const isValid = timingSafeEqual(Buffer.from(key, 'hex'), derivedKey);
Salt, Pepper, Timing
- Salt is unique per password and stored as part of the hash — it defeats rainbow tables. bcrypt and argon2 generate salt automatically
- Pepper is an application-level server-side secret — even if the DB leaks, the attacker can't crack passwords. Simple: hash = argon2(password + pepper). Keep the pepper in an env var
- Timing-safe compare — string comparison leaks timing info; use
crypto.timingSafeEqual - Generic error messages — don't distinguish "user does not exist" from "wrong password"; otherwise attackers enumerate emails
How to Pick the Work Factor
The main rule: the login response can take what a user will tolerate (~250ms). Measure the hash time on your server and tune toward that target. As hardware gets faster, raise the work factor every couple of years.
// Work factor calibration
async function measure() {
const password = 'test1234test';
for (let memoryCost of [4096, 8192, 19456, 46080, 65536]) {
const start = Date.now();
await argon2.hash(password, { type: argon2.argon2id, memoryCost, timeCost: 2 });
console.log(`memoryCost ${memoryCost} = ${Date.now() - start}ms`);
}
}
// Aim for around 250ms
OWASP 2023 Recommendations
- argon2id: 19 MiB memory, 2 iterations, 1 parallelism
- bcrypt: cost factor 10 (minimum), 12+ on modern hardware
- scrypt: N=2^17 (131072), r=8, p=1
- All passwords should accept at least 64 bytes of input (no truncation)
User Password Policy
- Minimum 12 characters — don't enforce complexity rules
- Haveibeenpwned API — reject breached passwords
- 2FA mandatory on critical accounts
- Don't force yearly password rotation — NIST no longer recommends it
- Encourage the use of a password manager
Migration Strategy
If your DB has old MD5/SHA256 hashes — don't do a bulk crack, do a lazy migration: when the user logs in, take the plaintext password, verify against the old hash, and on success re-hash with argon2. Within a few months most active users are migrated.
Conclusion
In 2026, for a greenfield project, pick argon2id. Existing projects on bcrypt are fine — just keep the cost factor high enough. A DB with MD5/SHA hashes is a security incident and you need to notify your users. Password hashing is, in the end, "protecting your users from attackers".
Reach out to KEYDAL for password hashing review, 2FA integration and breach response planning. Contact us