Argon2 vs bcrypt: Why I Chose Argon2id for Aegis2FA
Deep dive into password hashing algorithms, comparing bcrypt, scrypt, and Argon2. Learn why Argon2id won the Password Hashing Competition and how to implement it correctly.
The Password Hashing Problem
When I started building Aegis2FA, I needed to choose a password hashing algorithm. The stakes are high: weak password hashing is the difference between a minor breach and a catastrophic data leak.
The Contenders
bcrypt (1999)
The old reliable. Based on the Blowfish cipher.
Pros:
- Battle-tested for 25+ years
- Widely supported across languages
- Adaptive cost factor
Cons:
- Limited to 72 characters
- Memory usage is fixed (not configurable)
- Vulnerable to GPU acceleration
- No built-in resistance to side-channel attacks
scrypt (2009)
Designed to be memory-hard.
Pros:
- Configurable memory requirements
- Resistant to GPU/ASIC attacks
- Good for preventing hardware attacks
Cons:
- Complex parameter tuning
- Less widely adopted
- Can be difficult to implement correctly
Argon2 (2015)
Winner of the Password Hashing Competition.
Pros:
- Memory-hard with configurable parameters
- Side-channel resistance
- Three variants (Argon2d, Argon2i, Argon2id)
- Active development and security review
Cons:
- Newer (less battle-tested)
- Not available in all standard libraries
Why Argon2id Won
I chose Argon2id for Aegis2FA. Here's why:
1. Memory-Hardness
Argon2 fills a large memory buffer with pseudorandom data. This makes GPU/ASIC attacks exponentially more expensive:
import argon2 from 'argon2';
// Hash with 64 MB memory cost
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB in KiB
timeCost: 3, // 3 iterations
parallelism: 4, // 4 threads
});Why this matters: An attacker with a GPU farm still needs to allocate 64 MB per hash attempt. With bcrypt, they can run thousands in parallel with minimal memory.
2. Configurable Parameters
Argon2 has three tuning knobs:
interface Argon2Options {
memoryCost: number; // Memory in KiB
timeCost: number; // Number of iterations
parallelism: number; // Number of threads
}This lets you balance security vs. performance based on your infrastructure.
3. Side-Channel Resistance
Argon2id combines Argon2i (data-independent) and Argon2d (data-dependent):
- First pass: Data-independent (resistant to timing attacks)
- Subsequent passes: Data-dependent (resistant to time-memory tradeoffs)
bcrypt and scrypt don't provide this level of protection.
Implementation in Aegis2FA
Password Hashing
import argon2 from 'argon2';
export async function hashPassword(password: string): Promise<string> {
// Validate password first
if (password.length < 8 || password.length > 128) {
throw new Error('Password must be between 8 and 128 characters');
}
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 iterations
parallelism: 4, // 4 threads
hashLength: 32, // 32 bytes output
});
}Password Verification
export async function verifyPassword(
hash: string,
password: string
): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch (error) {
// argon2.verify throws on invalid hash format
return false;
}
}Handling Algorithm Upgrades
Store the algorithm version in the hash:
interface PasswordHash {
algorithm: 'argon2id' | 'bcrypt';
hash: string;
version: number;
}
export async function verifyAndUpgrade(
storedHash: PasswordHash,
password: string
): Promise<{ valid: boolean; needsUpgrade: boolean }> {
let valid = false;
// Verify with old algorithm
if (storedHash.algorithm === 'bcrypt') {
valid = await bcrypt.compare(password, storedHash.hash);
} else {
valid = await argon2.verify(storedHash.hash, password);
}
// Check if we need to upgrade
const needsUpgrade =
storedHash.algorithm !== 'argon2id' ||
storedHash.version < CURRENT_VERSION;
return { valid, needsUpgrade };
}Performance Benchmarks
I ran benchmarks on a 4-core, 8GB RAM server:
Algorithm | Time | Memory | Operations/sec
--------------|--------|---------|---------------
bcrypt (10) | 65ms | <1 MB | 15.4
scrypt (2^14) | 85ms | 16 MB | 11.8
argon2id | 95ms | 64 MB | 10.5
Analysis: Argon2id is slightly slower, but the memory usage makes it much more resistant to attacks.
Cost of Attacks
Assuming an attacker has:
- 1000 GPUs with 12 GB RAM each
- bcrypt: Can run ~12,000 hashes in parallel per GPU
- argon2id: Can run ~187 hashes in parallel per GPU (12GB / 64MB)
64x less parallelism for the same hardware cost.
Parameter Selection
Conservative (High Security)
{
memoryCost: 262144, // 256 MB
timeCost: 4,
parallelism: 4,
}
// ~350ms hash timeUse for: Admin accounts, financial apps
Balanced (Recommended)
{
memoryCost: 65536, // 64 MB
timeCost: 3,
parallelism: 4,
}
// ~95ms hash timeUse for: Most production apps
Fast (Low Latency)
{
memoryCost: 32768, // 32 MB
timeCost: 2,
parallelism: 2,
}
// ~45ms hash timeUse for: High-volume, latency-sensitive APIs
Common Mistakes
1. Using Default Parameters
Don't use library defaults blindly:
// BAD: Using defaults
const hash = await argon2.hash(password);
// GOOD: Explicit parameters
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
});2. Not Handling Memory Limits
In containerized environments, set memory limits:
# Dockerfile
ENV NODE_OPTIONS="--max-old-space-size=512"
# docker-compose.yml
services:
api:
mem_limit: 1g
mem_reservation: 512m3. Synchronous Hashing
Never use synchronous hashing in Node.js:
// BAD: Blocks event loop
const hash = argon2.hashSync(password);
// GOOD: Async
const hash = await argon2.hash(password);Security Audit Checklist
When implementing Argon2:
- Use argon2id variant (not argon2i or argon2d)
- Set memoryCost ≥ 65536 (64 MB)
- Set timeCost ≥ 3
- Set parallelism based on CPU cores (2-4)
- Hash asynchronously
- Rate limit password verification endpoints
- Log failed login attempts
- Implement account lockout after N failures
- Use constant-time comparison for hashes
- Store hash + parameters together
- Plan for algorithm upgrades
Migration from bcrypt
If you're migrating an existing app:
async function migratePassword(
userId: string,
password: string,
oldHash: string
): Promise<void> {
// Verify old hash
const valid = await bcrypt.compare(password, oldHash);
if (!valid) {
throw new Error('Invalid password');
}
// Generate new hash
const newHash = await argon2.hash(password);
// Update database
await db.users.update({
where: { id: userId },
data: {
passwordHash: newHash,
hashAlgorithm: 'argon2id',
hashVersion: 1,
},
});
}Migrate users opportunistically on login.
Results in Aegis2FA
After 6 months in production:
- Zero password breaches
- 95ms p95 login latency
- No memory issues with 64 MB cost
- Successfully migrated 100+ test users from bcrypt
Conclusion
Argon2id is the right choice for new projects in 2025. It provides:
- Maximum resistance to GPU/ASIC attacks
- Side-channel attack protection
- Flexible parameter tuning
- Future-proof design
The slightly higher CPU/memory cost is worth the security improvement.
Considering Argon2 for your project? Have questions about parameter tuning? Reach out or open an issue!