Secure Password Storage (bcrypt vs Argon2)
Password hashing is one of the most critical security decisions in any application. The choice between bcrypt and Argon2 matters less than configuring either one correctly. This guide covers both, along with what to avoid.
Never Store Plaintext Passwords
This should be obvious, but breaches still reveal plaintext databases. Hash every password before storage. No exceptions.
Never Use MD5, SHA-1, or SHA-256
General-purpose hash functions are designed to be fast. That is exactly what you do not want for passwords. A modern GPU can compute billions of SHA-256 hashes per second.
| Algorithm | Hashes per second (GPU) | Time to crack 8-char password |
|---|---|---|
| MD5 | ~60 billion | Seconds |
| SHA-256 | ~10 billion | Minutes |
| bcrypt (cost 12) | ~30,000 | Years |
| Argon2id | ~1,000 | Centuries |
Use a password hashing function — an algorithm specifically designed to be slow and resource-intensive.
bcrypt
The most widely deployed password hashing function. Battle-tested for over 25 years.
Implementation
import bcrypt from "bcrypt";
const SALT_ROUNDS = 12; // cost factor
// Hash a password
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
// Verify a password
async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash);
}
Choosing the Cost Factor
The cost factor (salt rounds) controls how many iterations bcrypt performs. Each increment doubles the work.
| Cost | Time (approx) | Use Case |
|---|---|---|
| 10 | ~100ms | Minimum acceptable |
| 12 | ~300ms | Good default |
| 14 | ~1s | High security |
Target 250-500ms per hash on your hardware. Benchmark on your production servers:
import bcrypt from "bcrypt";
async function benchmarkBcrypt() {
for (let rounds = 10; rounds <= 16; rounds++) {
const start = Date.now();
await bcrypt.hash("test-password", rounds);
console.log(`Cost ${rounds}: ${Date.now() - start}ms`);
}
}
bcrypt Limitations
- 72-byte input limit. bcrypt truncates passwords longer than 72 bytes. For most users this is fine, but if you accept passphrases, pre-hash with SHA-256:
import crypto from "crypto";
import bcrypt from "bcrypt";
async function hashLongPassword(password: string): Promise<string> {
// Pre-hash to handle passwords > 72 bytes
const prehash = crypto.createHash("sha256").update(password).digest("base64");
return bcrypt.hash(prehash, 12);
}
- CPU-only. bcrypt is CPU-hard but not memory-hard. GPUs and ASICs can be optimized for it (though it remains expensive).
Reference: OWASP — Password Storage Cheat Sheet
Argon2
The winner of the Password Hashing Competition (2015). Designed to be resistant to GPU and ASIC attacks by requiring large amounts of memory.
Variants
- Argon2d: data-dependent memory access. Resistant to GPU cracking but vulnerable to side-channel attacks.
- Argon2i: data-independent memory access. Resistant to side-channel attacks.
- Argon2id: hybrid. Use this one.
Reference: RFC 9106 — Argon2
Implementation
import argon2 from "argon2";
// Hash a password
async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id, // Argon2id variant
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 iterations
parallelism: 4, // 4 threads
});
}
// Verify a password
async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return argon2.verify(hash, password);
}
Choosing Parameters
| Parameter | Description | Recommended |
|---|---|---|
memoryCost | Memory in KiB | 65536 (64 MB) minimum |
timeCost | Number of iterations | 3 minimum |
parallelism | Number of threads | 1-4 |
type | Variant | argon2id |
OWASP recommendation: Argon2id with 19 MiB memory, iteration count of 2, and 1 degree of parallelism as minimum. Increase memory as high as your server allows while keeping hash time under 1 second.
Reference: OWASP — Password Storage Cheat Sheet (Argon2id)
Benchmark on Your Hardware
import argon2 from "argon2";
async function benchmarkArgon2() {
const configs = [
{ memoryCost: 19456, timeCost: 2, parallelism: 1 }, // OWASP minimum
{ memoryCost: 65536, timeCost: 3, parallelism: 4 }, // moderate
{ memoryCost: 131072, timeCost: 4, parallelism: 4 }, // high
];
for (const config of configs) {
const start = Date.now();
await argon2.hash("test-password", { type: argon2.argon2id, ...config });
console.log(`${JSON.stringify(config)}: ${Date.now() - start}ms`);
}
}
bcrypt vs Argon2: When to Choose Which
| Factor | bcrypt | Argon2id |
|---|---|---|
| Maturity | 25+ years | 10 years |
| GPU resistance | CPU-hard only | CPU + memory-hard |
| Library support | Everywhere | Most languages |
| Compliance | Widely accepted | Increasingly recommended |
| Max input length | 72 bytes | No practical limit |
| Recommendation | Good default | Best choice for new projects |
For new projects, use Argon2id. It provides stronger guarantees against hardware-accelerated attacks.
bcrypt is still acceptable and significantly better than any non-password-hashing algorithm. If your ecosystem has better bcrypt support, use it.
Upgrading Hash Algorithms
If you are migrating from bcrypt to Argon2 (or increasing cost parameters), re-hash on login:
async function loginAndUpgradeHash(
email: string,
password: string
): Promise<User | null> {
const user = await db.user.findUnique({ where: { email } });
if (!user) return null;
// Verify against current hash
const valid = user.passwordHash.startsWith("$argon2")
? await argon2.verify(user.passwordHash, password)
: await bcrypt.compare(password, user.passwordHash);
if (!valid) return null;
// Re-hash with current algorithm if needed
if (!user.passwordHash.startsWith("$argon2")) {
const newHash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
});
await db.user.update({
where: { id: user.id },
data: { passwordHash: newHash },
});
}
return user;
}
Additional Defenses
Pepper
A pepper is a secret key applied before hashing. Unlike the salt (which is stored with the hash), the pepper is stored separately (environment variable or HSM).
const PEPPER = process.env.PASSWORD_PEPPER!;
async function hashWithPepper(password: string): Promise<string> {
const peppered = crypto
.createHmac("sha256", PEPPER)
.update(password)
.digest("base64");
return argon2.hash(peppered, { type: argon2.argon2id, memoryCost: 65536 });
}
If the database is breached but the pepper is not, the hashes are significantly harder to crack.
Password Strength Requirements
- Minimum 8 characters (NIST recommends supporting up to 64).
- Check against known breached passwords (Have I Been Pwned API).
- Do not enforce composition rules (uppercase + special character) — they do not improve security and annoy users.
Reference: NIST SP 800-63B — Digital Identity Guidelines
Checklist
- Using bcrypt (cost 12+) or Argon2id (not MD5, SHA-1, SHA-256)
- Hash time benchmarked on production hardware (target 250ms–1s)
- Salt generated automatically by the library (bcrypt and Argon2 do this)
- Pepper applied before hashing (stored outside the database)
- No password length maximum under 64 characters
- Breached password check integrated (Have I Been Pwned)
- Hash upgrade path implemented (re-hash on login)
Related Guides
Content is AI-assisted and reviewed by our team, but issues may be missed and best practices evolve rapidly, send corrections to [email protected]. Always consult official documentation and validate key implementation decisions before making design or security choices.
