There's a recurring question in auth engineering circles: "Should we migrate from bcrypt to Argon2?" The answer, like most security engineering answers, is "it depends." Bcrypt is not broken. Argon2 is better in specific threat models. Knowing which situation you're in matters more than chasing the newest algorithm. This post covers both in detail, plus the practical mechanics of upgrading hashes without logging users out.
Why password hashing is different from data hashing
A regular cryptographic hash (SHA-256, SHA-3) is designed to be fast — you might hash gigabytes of data for integrity verification. A password hash must be deliberately slow and expensive. The reason: if an attacker steals your database, they're going to try to crack the passwords offline at GPU speed. A fast hash lets them try billions of guesses per second. A slow, memory-hard hash limits them to thousands.
The two properties you're engineering for:
- Cost: Make each hash attempt expensive in CPU time
- Memory hardness: Make each attempt require significant RAM, neutralizing GPU parallelism
bcrypt: still reasonable, but has limits
bcrypt (1999) was designed for password hashing specifically. Its cost factor is a power of 2: cost=10 means 2¹⁰ = 1024 iterations, cost=12 means 4096 iterations. On modern hardware, cost=12 takes roughly 250ms per hash — enough to frustrate offline attacks while remaining acceptable for login latency.
import bcrypt from 'bcrypt';
const BCRYPT_COST = 12; // ~250ms on modern hardware, adjust per benchmarks
async function hashPassword(plaintext: string): Promise<string> {
return bcrypt.hash(plaintext, BCRYPT_COST);
}
async function verifyPassword(plaintext: string, hash: string): Promise<boolean> {
return bcrypt.compare(plaintext, hash);
}
// bcrypt output format (PHC-like):
// $2b$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
// ^ ^ ^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// alg cost salt (22 chars) hash (31 chars)
bcrypt's limitations in 2025:
- 72-byte password limit — passwords longer than 72 bytes are silently truncated. This is a real issue if users set very long passwords or passphrases.
- Not memory-hard — GPUs can attack bcrypt more effectively than Argon2 because bcrypt doesn't require much RAM per attempt.
- The cost parameter only controls CPU time, not memory. As CPUs get faster, you need to increment the cost factor periodically.
Argon2id: better default for new systems
Argon2 won the Password Hashing Competition in 2015. It has three variants:
- Argon2d — maximizes resistance to GPU cracking (data-dependent memory access). Vulnerable to side-channel attacks. Don't use for passwords.
- Argon2i — data-independent memory access. Resistant to side-channels. But weaker against GPU attacks.
- Argon2id — hybrid of both. The recommended choice for password hashing.
The OWASP recommendation for Argon2id (2024): m=64MB, t=3, p=4. These parameters mean: 64 MB of memory, 3 passes, 4 parallel threads.
import argon2 from 'argon2';
// OWASP 2024 recommended parameters
const ARGON2_OPTIONS: argon2.Options = {
type: argon2.argon2id,
memoryCost: 64 * 1024, // 64 MB in KiB
timeCost: 3, // 3 passes
parallelism: 4, // 4 threads
hashLength: 32, // 32-byte output
};
async function hashPasswordArgon2(plaintext: string): Promise<string> {
return argon2.hash(plaintext, ARGON2_OPTIONS);
}
async function verifyPasswordArgon2(plaintext: string, hash: string): Promise<boolean> {
return argon2.verify(hash, plaintext);
}
// Argon2 output (PHC string format):
// $argon2id$v=19$m=65536,t=3,p=4$c2FsdGhlcmU$hash_base64_here
The PHC string format
Both bcrypt and Argon2 use self-describing hash strings that embed the algorithm, parameters, salt, and hash together. This is critical: it means you can change your hashing parameters in the future and still verify old passwords, because the old parameters are stored in the hash string itself.
PHC format:
$algorithm$param1=value1,param2=value2$salt_base64$hash_base64
bcrypt:
$2b$12$saltHere (22 chars)hashHere (31 chars)
Argon2id:
$argon2id$v=19$m=65536,t=3,p=4$saltHere$hashHere
When you parse a stored hash to verify a password, you extract the algorithm identifier first and route to the correct verifier. This enables seamless algorithm migration.
Peppers: an additional layer
A pepper is a secret value added to the password before hashing, stored in application configuration (not the database). It provides an extra layer: if an attacker dumps your database, they also need the pepper to crack any passwords. If they only have the database dump, the hashes are useless without the pepper.
function applyPepper(password: string): string {
const pepper = process.env.PASSWORD_PEPPER;
if (!pepper) throw new Error('PASSWORD_PEPPER env var not set');
// HMAC with the pepper to produce a fixed-size, peppered value
return createHmac('sha256', pepper).update(password).digest('hex');
}
async function hashWithPepper(plaintext: string): Promise<string> {
const peppered = applyPepper(plaintext);
return argon2.hash(peppered, ARGON2_OPTIONS);
}
async function verifyWithPepper(plaintext: string, hash: string): Promise<boolean> {
const peppered = applyPepper(plaintext);
return argon2.verify(hash, peppered);
}
Transparent hash upgrade on login
When you change hashing algorithms or increase cost factors, you can't re-hash all passwords in a batch — you don't have the plaintexts. The only time you have a plaintext password is on login. This is your upgrade window:
async function loginWithHashUpgrade(
email: string,
plaintext: string,
db: DB
): Promise<User | null> {
const user = await db.users.findByEmail(email);
if (!user) return null;
// Detect algorithm from PHC string prefix
const isArgon2 = user.passwordHash.startsWith('$argon2');
const isBcrypt = user.passwordHash.startsWith('$2');
let valid = false;
if (isArgon2) {
valid = await argon2.verify(user.passwordHash, applyPepper(plaintext));
} else if (isBcrypt) {
valid = await bcrypt.compare(plaintext, user.passwordHash);
} else {
throw new Error(`Unknown hash format for user ${user.id}`);
}
if (!valid) return null;
// If the hash isn't using our current preferred algorithm/params, upgrade it
if (!isArgon2 || needsRehash(user.passwordHash)) {
const newHash = await hashWithPepper(plaintext);
await db.users.updatePasswordHash(user.id, newHash);
}
return user;
}
function needsRehash(hash: string): boolean {
// argon2.needsRehash checks if the stored hash uses different params than current
return argon2.needsRehash(hash, ARGON2_OPTIONS);
}
Over time, as users log in, their hashes silently upgrade to the new algorithm. Users who haven't logged in in years will still have old-format hashes — that's acceptable. Force a password reset for dormant accounts if you have a strict compliance requirement to upgrade all hashes within a time window.
When bcrypt is fine
If you're already using bcrypt at cost=12 or higher, you have good security. Migration to Argon2 makes sense if:
- You're building a new system and want the better default
- Your threat model includes well-funded attackers with significant GPU resources
- You have users with passwords longer than 72 bytes (edge case, but real)
- A compliance framework explicitly requires Argon2
If none of those apply, don't break what works. Migrate incrementally using the on-login upgrade pattern above — there's no rush, and a botched migration that locks users out is far worse than staying on bcrypt for another year.