Storing user passwords securely is one of the most critical responsibilities in software engineering. Despite decades of cryptographic warnings, systems still fall victim to database breaches where attackers crack millions of hashes within hours.
Standard cryptographic hash functions like SHA-256, SHA-512, and MD5 were engineered for speed and message integrity. On modern commodity hardware, an attacker with an RTX 4090 GPU can compute over 20 billion SHA-256 hashes per second. Using SHA-256 for password storage—even with a unique salt—is a critical vulnerability.
Modern password storage requires adaptive, computationally expensive, and memory-hard Key Derivation Functions (KDFs).
This guide provides a comprehensive technical comparison of Argon2id, bcrypt, and PBKDF2, complete with OWASP configuration guidelines, production code samples, and automated hash upgrade patterns.
1. Algorithmic Comparison: Argon2id vs bcrypt vs PBKDF2
+-----------------------------------------------------------------------------------------+
| KDF Landscape |
+-----------------------------------------------------------------------------------------+
| 1. PBKDF2 (NIST SP 800-132) -> CPU intensive, 0 memory requirement (GPU-vulnerable) |
| 2. bcrypt (1999) -> 4 KB working set, 72-byte input cap (ASIC-resistant) |
| 3. scrypt (2009) -> Tunable memory & CPU hardness |
| 4. Argon2id (RFC 9106) -> Gold Standard: Memory-hard + Side-Channel Resistant |
+-----------------------------------------------------------------------------------------+
Direct Feature Comparison Matrix
| Metric | Argon2id (Recommended) | bcrypt | PBKDF2 (HMAC-SHA256) |
|---|---|---|---|
| Cryptographic Design | Memory-hard directed acyclic graph | Modified Eksblowfish key schedule | Repeated HMAC iterations |
| Primary Defense | High configurable RAM + CPU time | 4 KB internal state table | High iteration count |
| GPU/ASIC Resistance | Exceptional (Memory-bandwidth bound) | High (L1 cache-bound) | Poor (Easily parallelized on GPUs) |
| Side-Channel Resistance | High (Data-independent/dependent hybrid) | Medium (Cache timing variations) | High |
| Max Password Length | Unlimited (up to $2^{32}-1$ bytes) | 72 bytes max (Silently truncated) | Unlimited |
| Standard / RFC | RFC 9106 / PHC Winner | OpenBSD Defacto Standard | NIST SP 800-132 / RFC 2898 |
| Recommended OWASP Target | m=64MB, t=3, p=4 (or m=19MB, t=2, p=1) |
cost=12 |
iterations=600,000 |
2. Why Argon2id is the Gold Standard
Argon2 was selected as the winner of the Password Hashing Competition (PHC) in 2015 and formalized in RFC 9106. It has three variants:
- Argon2d: Data-dependent memory access. Highest resistance against GPU cracking, but vulnerable to cache-timing side-channel attacks.
- Argon2i: Data-independent memory access. Immune to side-channel attacks, but slightly less resistant to GPU memory trade-offs.
- Argon2id (Standard): A hybrid approach. Uses Argon2i for the first pass over memory to eliminate side-channel vulnerabilities, then switches to Argon2d for remaining passes to maximize GPU attack resistance.
Recommended Parameters (OWASP 2026)
| Environment | Memory (m) |
Iterations (t) |
Parallelism (p) |
Target Latency |
|---|---|---|---|---|
| High Security / Low Concurrency | 64 MB (65536 KiB) |
3 passes | 4 threads | ~250–500 ms |
| Standard SaaS / High Concurrency | 19 MB (19456 KiB) |
2 passes | 1 thread | ~50–100 ms |
| Memory-Constrained Microservices | 12 MB (12288 KiB) |
3 passes | 1 thread | ~40–80 ms |
3. The 72-Byte Truncation Trap in bcrypt
A dangerous pitfall of bcrypt is that it silently truncates passwords longer than 72 bytes:
Password: "ThisIsAnExtremelyLongPassphraseThatExceedsSeventyTwoBytesInLength1234567890ABC"
Truncated by bcrypt to: "ThisIsAnExtremelyLongPassphraseThatExceedsSeventyTwoBytesInLength12345678"
Any characters typed after byte 72 are completely ignored! An attacker only needs to guess the first 72 bytes.
The Correct Fix: Pre-hashing with SHA-256 / HMAC
If using bcrypt, hash the input with SHA-256 (in binary representation or base64) before feeding it to bcrypt:
import crypto from 'node:crypto';
import bcrypt from 'bcrypt';
async function hashPasswordBcryptSafe(password: string): Promise<string> {
// SHA-256 produces a fixed 32-byte digest (always safe under 72-byte limit)
const preHashed = crypto.createHash('sha256').update(password).digest('base64');
const saltRounds = 12;
return bcrypt.hash(preHashed, saltRounds);
}
4. Production Code Implementations
Node.js / TypeScript (with @node-rs/argon2)
The @node-rs/argon2 library provides SIMD-accelerated, native bindings:
import { hash, verify, argon2id } from '@node-rs/argon2';
// 1. Hash with OWASP Standard parameters
export async function hashPassword(plainPassword: string): Promise<string> {
return hash(plainPassword, {
algorithm: argon2id,
memoryCost: 19456, // 19 MB
timeCost: 2, // 2 passes
parallelism: 1, // 1 thread
outputLen: 32, // 32-byte hash
});
}
// 2. Constant-time verification
export async function verifyPassword(storedHash: string, plainPassword: string): Promise<boolean> {
try {
return await verify(storedHash, plainPassword);
} catch {
return false;
}
}
Python (with argon2-cffi)
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError
# Initialize hasher with OWASP parameters
ph = PasswordHasher(
time_cost=2,
memory_cost=19456, # 19 MB
parallelism=1,
hash_len=32,
salt_len=16
)
# Hash password
hashed = ph.hash("user-secure-passphrase-2026")
# Verify password and detect if parameters need updating
def check_user_login(user_record, submitted_password):
try:
ph.verify(user_record.password_hash, submitted_password)
# Check if algorithm parameters need an in-place upgrade
if ph.check_needs_rehash(user_record.password_hash):
user_record.password_hash = ph.hash(submitted_password)
user_record.save()
return True
except (VerifyMismatchError, VerificationError):
return False
5. Zero-Downtime Migration: Upgrading Legacy Hashes on Login
When migrating a legacy database from MD5, SHA-256, or weak bcrypt to Argon2id, you cannot re-hash passwords in bulk because the plaintext passwords are not stored.
The industry-standard solution is in-place re-hashing on authenticated login:
import { verifyPassword, hashPassword } from './crypto';
async function handleLogin(user, attemptedPassword) {
let isValid = false;
let needsRehash = false;
if (user.passwordHash.startsWith('$argon2id$')) {
isValid = await verifyPassword(user.passwordHash, attemptedPassword);
// Optional: check if memory/time parameters need upgrading
} else if (user.passwordHash.startsWith('$2b$') || user.passwordHash.startsWith('$2a$')) {
// Legacy bcrypt hash
isValid = await verifyLegacyBcrypt(user.passwordHash, attemptedPassword);
needsRehash = isValid;
} else if (user.passwordHash.length === 64) {
// Legacy SHA-256 hash (upgrade immediately!)
isValid = verifyLegacySha256(user.passwordHash, user.salt, attemptedPassword);
needsRehash = isValid;
}
if (!isValid) {
throw new Error('Invalid credentials');
}
// Seamlessly upgrade database record to Argon2id
if (needsRehash) {
const newHash = await hashPassword(attemptedPassword);
await db.user.update({
where: { id: user.id },
data: { passwordHash: newHash, salt: null }
});
}
return createSession(user.id);
}
6. Summary Checklist
- Default to Argon2id for all new software systems.
- Allocate at least 19 MB of memory (
m=19456) and 2 iterations (t=2) per hash. - If constrained to bcrypt, set
cost >= 12and pre-hash with SHA-256 to bypass the 72-byte cap. - Never write custom crypto; rely on verified native libraries (
@node-rs/argon2,argon2-cffi). - Always use constant-time verification to protect against timing side-channels.
Use the Hash Generator and Password Generator tools to test cryptographic digests and generate high-entropy passphrases.