bcrypt vs argon2 vs scrypt: Password Hashing in 2026
TL;DR
For new projects in 2026: use Argon2id. OWASP, NIST, and virtually every security authority recommends it as the gold standard for password hashing. bcrypt is still safe but limited to 72-byte passwords and was designed in 1999. scrypt is solid but less widely reviewed than Argon2. If you're on bcrypt today, it's fine to stay — but new code should default to Argon2id.
Key Takeaways
- Argon2id: OWASP's #1 recommendation — winner of the Password Hashing Competition (PHC), memory-hard, GPU-resistant
- bcrypt: Still safe, proven over 25 years, but 72-byte max and no parallelism parameter
- scrypt: Memory-hard like Argon2 but fewer tuning options and less peer-reviewed
- npm packages:
bcrypt(~1.8M/wk),bcryptjs(~3.2M/wk pure-JS, slower),argon2(~850K/wk) - Never use: MD5, SHA-1, SHA-256 for passwords — they're too fast (built for speed, not security)
- All three are fine for existing production systems — priority for new code: Argon2id
The Fundamental Rule
Password hashing is not the same as regular hashing (SHA-256, MD5). A password hash needs to be:
- Slow on purpose — to resist brute-force attacks
- Memory-hard — to defeat GPU/ASIC acceleration
- Salted — to prevent rainbow table attacks
SHA-256 can hash 1 billion passwords per second on a GPU. bcrypt can hash ~20. Argon2id can hash ~1 (with proper settings). That difference is the entire security story.
Download Trends
| Package | Weekly Downloads | Language | Notes |
|---|---|---|---|
bcryptjs | ~3.2M | Pure JavaScript | Slower, no native deps |
bcrypt | ~1.8M | C++ bindings | Faster, requires node-gyp |
argon2 | ~850K | C++ bindings | OWASP recommended |
@node-rs/argon2 | ~120K | Rust via napi-rs | Fastest Argon2 impl |
scrypt-js | ~4.1M | Pure JavaScript | Used by ethers.js (wallets) |
scrypt-js downloads are inflated by crypto/wallet libraries, not password hashing use cases.
bcrypt
bcrypt was designed in 1999 and has been scrutinized by the security community for 25+ years. No significant vulnerabilities have been found.
import bcrypt from "bcrypt"
// Hashing (async — never use sync in request handlers)
const SALT_ROUNDS = 12 // 2^12 iterations — ~250ms on modern hardware
async function hashPassword(plaintext: string): Promise<string> {
return bcrypt.hash(plaintext, SALT_ROUNDS)
}
async function verifyPassword(
plaintext: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(plaintext, hash)
}
// Usage:
const hash = await hashPassword("hunter2")
// "$2b$12$..." — bcrypt hash with embedded salt and cost factor
const valid = await verifyPassword("hunter2", hash) // true
const invalid = await verifyPassword("wrong", hash) // false
bcrypt limitations:
-
72-byte truncation: bcrypt silently truncates passwords to 72 bytes. "password_with_very_long_suffix_that_exceeds_72_bytes_total" and "password_with_very_long_suffix_that_exceeds_72_bytes_alter" hash to the same value.
// Mitigation: pre-hash with SHA-256 (not ideal but works) import { createHash } from "crypto" function preprocess(password: string): string { return createHash("sha256").update(password).digest("hex") } // Then: bcrypt.hash(preprocess(password), SALT_ROUNDS) -
No memory-hardness: Modern GPUs can run many bcrypt instances in parallel. Cost factor 12 provides good protection today but may weaken as hardware improves.
-
Cost factor only: Single parameter to tune speed — no separate memory or parallelism controls.
When bcrypt is fine:
- Existing production systems — don't migrate just to migrate
- Projects where native C++ dependencies are a problem (use bcryptjs instead)
- Teams where bcrypt expertise exists and Argon2 is unfamiliar
Argon2
Argon2 won the Password Hashing Competition in 2015, specifically designed to address bcrypt's limitations.
import argon2 from "argon2"
// Argon2id (the recommended variant — hybrid of Argon2i and Argon2d)
async function hashPassword(plaintext: string): Promise<string> {
return argon2.hash(plaintext, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB — OWASP minimum recommendation
timeCost: 3, // iterations
parallelism: 4, // threads
})
}
async function verifyPassword(
plaintext: string,
hash: string
): Promise<boolean> {
return argon2.verify(hash, plaintext)
}
// Hash format: $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>
// All parameters are embedded — verify() auto-reads them
Three Argon2 variants:
- Argon2id — Use this. Hybrid of the other two, recommended by OWASP for password hashing
- Argon2i — Optimized against side-channel attacks; used for password-based key derivation (not password storage)
- Argon2d — Maximizes GPU resistance; not recommended for password hashing (vulnerable to side-channel)
OWASP 2026 minimum settings:
Argon2id: m=19456 (19MB), t=2, p=1 — fast devices (login endpoints)
Argon2id: m=65536 (64MB), t=3, p=4 — higher security contexts
Argon2 advantages over bcrypt:
- No password length limit
- Memory-hard — resistant to GPU/ASIC attacks
- Three tunable parameters: memory, time, parallelism
- PHC winner — extensively peer-reviewed since 2015
- RFC 9106 standardized (2021)
scrypt
scrypt is the memory-hard algorithm used in Litecoin and many cryptocurrency wallets. Node.js has it built in:
import { scrypt, randomBytes, timingSafeEqual } from "crypto"
import { promisify } from "util"
const scryptAsync = promisify(scrypt)
// OWASP recommended parameters: N=32768 (2^15), r=8, p=1
const N = 32768 // CPU/memory cost
const r = 8 // block size
const p = 1 // parallelism
const KEYLEN = 64
async function hashPassword(plaintext: string): Promise<string> {
const salt = randomBytes(32).toString("hex")
const derivedKey = await scryptAsync(plaintext, salt, KEYLEN, { N, r, p }) as Buffer
return `${salt}:${derivedKey.toString("hex")}`
}
async function verifyPassword(
plaintext: string,
storedHash: string
): Promise<boolean> {
const [salt, hash] = storedHash.split(":")
const derivedKey = await scryptAsync(plaintext, salt, KEYLEN, { N, r, p }) as Buffer
const storedKey = Buffer.from(hash, "hex")
return timingSafeEqual(derivedKey, storedKey)
}
Note: scrypt has no standard hash encoding format — you must store salt separately (as shown above) or implement your own format. This is a footgun compared to bcrypt and Argon2, which embed salt in the hash string.
scrypt compared to Argon2:
- Less peer-reviewed than Argon2
- Memory requirement =
N * r * 128bytes — less intuitive to reason about - No recommended parameters from PHC (there was no competition winner)
- Still technically sound, widely used in crypto wallets
Security Comparison
| Property | bcrypt | Argon2id | scrypt |
|---|---|---|---|
| OWASP recommended | ⭐⭐⭐ (2nd tier) | ⭐⭐⭐⭐⭐ (1st tier) | ⭐⭐⭐⭐ (2nd tier) |
| Memory-hard | ❌ | ✅ | ✅ |
| GPU-resistant | ⚠️ Moderate | ✅ Strong | ✅ Strong |
| Max password length | 72 bytes | None | None |
| Salt handling | Auto-embedded | Auto-embedded | Manual |
| Standardized | No formal RFC | RFC 9106 | RFC 7914 |
| Peer review | 25+ years | 10 years (PHC) | 15+ years |
| Tuning | Cost factor only | Memory + time + threads | Cost + block + threads |
Performance Benchmarks
Approximate timing on modern hardware (Apple M3, measuring one hash operation):
| Algorithm | Settings | Time | Memory Used |
|---|---|---|---|
| bcrypt | cost=10 | ~65ms | <1MB |
| bcrypt | cost=12 | ~250ms | <1MB |
| Argon2id | m=64MB, t=3 | ~180ms | 64MB |
| Argon2id | m=19MB, t=2 | ~55ms | 19MB |
| scrypt | N=32768 | ~120ms | 32MB |
Target: 100–300ms per hash operation is the accepted sweet spot for login endpoints.
Recommended Settings by Use Case
// Login endpoint (high-volume, need responsiveness):
const LOGIN_SETTINGS = {
type: argon2.argon2id,
memoryCost: 19456, // 19MB
timeCost: 2,
parallelism: 1,
}
// Admin/high-value accounts (can afford more time):
const ADMIN_SETTINGS = {
type: argon2.argon2id,
memoryCost: 65536, // 64MB
timeCost: 3,
parallelism: 4,
}
// Password reset / account creation (one-time, latency acceptable):
const HIGH_SECURITY_SETTINGS = {
type: argon2.argon2id,
memoryCost: 131072, // 128MB
timeCost: 4,
parallelism: 4,
}
Migrating from bcrypt to Argon2
Don't rehash all passwords at once — use a lazy migration on login:
import bcrypt from "bcrypt"
import argon2 from "argon2"
async function loginUser(plaintext: string, storedHash: string) {
let verified = false
if (storedHash.startsWith("$2b$") || storedHash.startsWith("$2a$")) {
// Legacy bcrypt hash
verified = await bcrypt.compare(plaintext, storedHash)
if (verified) {
// Upgrade to Argon2id on successful login
const newHash = await argon2.hash(plaintext, {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
})
await updateUserPasswordHash(userId, newHash)
}
} else if (storedHash.startsWith("$argon2")) {
// New Argon2 hash
verified = await argon2.verify(storedHash, plaintext)
}
return verified
}
This migrates users transparently over time with zero downtime.
When to Use Each
Choose Argon2id if:
- Starting any new project in 2026
- You want OWASP-compliant password storage
- You need fine-grained tuning (memory, time, parallelism)
- You want protection against GPU/ASIC brute-force
Choose bcrypt if:
- You're maintaining an existing bcrypt-based system
- Native C++ dependencies are blocked (use bcryptjs)
- Your passwords are guaranteed to be under 72 bytes (or you pre-hash)
Use scrypt if:
- You're building something in the cryptocurrency/Web3 space (convention)
- You specifically need the built-in Node.js crypto module (no external deps)
Never use for passwords:
- MD5, SHA-1, SHA-256, SHA-512 — too fast, designed for data integrity not passwords
- PBKDF2 — technically OWASP-compliant but inferior to Argon2 and bcrypt
Methodology
Security recommendations sourced from OWASP Password Storage Cheat Sheet (2025 update), NIST SP 800-63B, and RFC 9106 (Argon2). Download counts represent npm weekly averages (February 2026). Performance benchmarks measured with node:crypto and argon2 npm package on Apple M3 hardware.