Password Hashing: bcrypt, scrypt, and Argon2
When attackers breach your database, the first thing they target is the users table. If you've stored passwords in plain text, every account is immediately compromised. If you've used a fast hash...
Key Insights
- Password hashing algorithms are intentionally slow by design—this computational cost is your primary defense against offline brute-force attacks, and tuning work factors correctly matters more than algorithm choice.
- Argon2id is the recommended algorithm for new applications in 2024, but bcrypt remains perfectly secure and battle-tested for existing systems—don’t migrate just for the sake of modernity.
- The most common password hashing vulnerabilities aren’t algorithm weaknesses but implementation mistakes: insufficient work factors, timing attacks in verification, and failing to upgrade hashes over time.
Why Password Hashing Matters
When attackers breach your database, the first thing they target is the users table. If you’ve stored passwords in plain text, every account is immediately compromised. If you’ve used a fast hash like MD5 or SHA-256, you’ve bought yourself perhaps a few hours.
Hashing differs fundamentally from encryption. Encryption is reversible—you can decrypt ciphertext back to plaintext with the right key. Hashing is a one-way function. You can verify that an input produces a specific hash, but you cannot reverse the hash to recover the original input.
The problem with fast cryptographic hashes is exactly their speed. Modern GPUs can compute billions of SHA-256 hashes per second. An attacker with a stolen database can try every common password, every dictionary word with common substitutions, and every combination up to a reasonable length in hours or days.
Rainbow tables made this even worse. Attackers precomputed hashes for billions of common passwords and stored them in lookup tables. Cracking became a simple database query.
Salting—prepending a random value to each password before hashing—defeated rainbow tables by making precomputation infeasible. But salts alone don’t slow down targeted attacks against individual passwords. For that, you need algorithms designed to be slow.
Understanding Key Stretching and Work Factors
Password hashing algorithms use key stretching to make each hash computation expensive. The idea is simple: if computing one hash takes 100 milliseconds instead of 100 nanoseconds, an attacker’s brute-force attack becomes a million times slower.
Work factors (also called cost parameters) control this computational expense. You tune them based on your hardware and acceptable latency. A login that takes 250ms to verify is acceptable; 5 seconds is not. But you want to push as close to that acceptable limit as possible.
Two types of hardness matter:
CPU-hard functions require many computational cycles. bcrypt falls into this category. Attackers can parallelize these across many cores, but each core can only work so fast.
Memory-hard functions require significant RAM to compute. scrypt and Argon2 use this approach. GPUs and ASICs have limited memory bandwidth, making them far less effective at parallelizing memory-hard algorithms.
import time
import hashlib
import bcrypt
from argon2 import PasswordHasher
password = b"correct-horse-battery-staple"
# SHA-256: Dangerously fast
start = time.perf_counter()
for _ in range(100000):
hashlib.sha256(password).hexdigest()
sha_time = time.perf_counter() - start
print(f"SHA-256: {100000 / sha_time:.0f} hashes/second")
# bcrypt with cost factor 12
start = time.perf_counter()
for _ in range(10):
bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
bcrypt_time = (time.perf_counter() - start) / 10
print(f"bcrypt (cost=12): {bcrypt_time * 1000:.0f}ms per hash")
# Argon2id with default parameters
ph = PasswordHasher()
start = time.perf_counter()
for _ in range(10):
ph.hash(password.decode())
argon_time = (time.perf_counter() - start) / 10
print(f"Argon2id: {argon_time * 1000:.0f}ms per hash")
On typical server hardware, you’ll see SHA-256 compute millions of hashes per second, while bcrypt and Argon2 take hundreds of milliseconds each. That’s the point.
bcrypt: The Proven Standard
bcrypt has protected passwords since 1999. It’s based on the Blowfish cipher and was specifically designed for password hashing. After 25 years of cryptanalysis, it remains unbroken.
bcrypt automatically generates and embeds a 128-bit salt in the output hash. The cost factor (also called rounds or work factor) is a power of 2—a cost of 12 means 2^12 (4,096) iterations.
The main limitation is bcrypt’s 72-byte input cap. Passwords longer than 72 bytes are silently truncated. In practice, this rarely matters—72 characters is a long password—but it’s worth knowing.
import bcrypt
def hash_password(password: str) -> str:
"""Hash a password with bcrypt using a secure cost factor."""
# Cost factor 12 is minimum recommended for 2024
# Increase to 13 or 14 if your hardware allows
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
return hashed.decode('utf-8')
def verify_password(password: str, hashed: str) -> bool:
"""Verify a password against its bcrypt hash."""
return bcrypt.checkpw(
password.encode('utf-8'),
hashed.encode('utf-8')
)
# Usage
stored_hash = hash_password("hunter2")
print(f"Hash: {stored_hash}")
# Output: $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.G5HmGHzta5z5Oe
print(verify_password("hunter2", stored_hash)) # True
print(verify_password("hunter3", stored_hash)) # False
const bcrypt = require('bcrypt');
async function hashPassword(password) {
const saltRounds = 12;
return await bcrypt.hash(password, saltRounds);
}
async function verifyPassword(password, hash) {
return await bcrypt.compare(password, hash);
}
// Usage
(async () => {
const hash = await hashPassword('hunter2');
console.log(`Hash: ${hash}`);
console.log(await verifyPassword('hunter2', hash)); // true
console.log(await verifyPassword('hunter3', hash)); // false
})();
For cost factor selection in 2024: start at 12 and increase until hash computation takes 250-500ms on your production hardware. Re-evaluate annually as hardware improves.
scrypt: Memory-Hard Protection
scrypt introduced memory-hardness to password hashing in 2009. The algorithm requires a configurable amount of RAM to compute, making GPU and ASIC attacks dramatically more expensive.
Three parameters control scrypt’s behavior:
- N: CPU/memory cost parameter (must be power of 2). Higher = more memory and time.
- r: Block size parameter. Affects memory usage and mixing.
- p: Parallelization parameter. Higher allows more parallel computation.
Memory usage is approximately 128 * N * r bytes.
import hashlib
import os
def hash_password_scrypt(password: str) -> bytes:
"""Hash password with scrypt using secure parameters."""
salt = os.urandom(32)
# N=2^17 (131072), r=8, p=1 uses ~16MB RAM
# Adjust N based on your memory constraints
derived_key = hashlib.scrypt(
password.encode('utf-8'),
salt=salt,
n=2**17,
r=8,
p=1,
dklen=32
)
# Store salt + derived key together
return salt + derived_key
def verify_password_scrypt(password: str, stored: bytes) -> bool:
"""Verify password against scrypt hash."""
salt = stored[:32]
stored_key = stored[32:]
derived_key = hashlib.scrypt(
password.encode('utf-8'),
salt=salt,
n=2**17,
r=8,
p=1,
dklen=32
)
# Use constant-time comparison
return hmac.compare_digest(derived_key, stored_key)
scrypt’s memory requirements make it excellent for defending against hardware attacks, but the complexity of tuning three parameters correctly has led many to prefer Argon2.
Argon2: The Modern Choice
Argon2 won the Password Hashing Competition in 2015 and represents the current state of the art. It comes in three variants:
- Argon2d: Maximizes resistance to GPU attacks but vulnerable to side-channel attacks
- Argon2i: Resistant to side-channel attacks, slightly less resistant to GPU attacks
- Argon2id: Hybrid that provides both protections. Use this one.
Argon2 parameters are more intuitive than scrypt’s:
- memory_cost: RAM usage in kibibytes
- time_cost: Number of iterations
- parallelism: Number of threads
from argon2 import PasswordHasher, Type
from argon2.exceptions import VerifyMismatchError
# Configure with secure defaults for 2024
ph = PasswordHasher(
time_cost=3, # 3 iterations
memory_cost=65536, # 64 MB
parallelism=4, # 4 threads
hash_len=32,
salt_len=16,
type=Type.ID # Argon2id
)
def hash_password(password: str) -> str:
"""Hash password with Argon2id."""
return ph.hash(password)
def verify_password(password: str, hash: str) -> bool:
"""Verify password and check if rehash needed."""
try:
ph.verify(hash, password)
return True
except VerifyMismatchError:
return False
def needs_rehash(hash: str) -> bool:
"""Check if hash uses outdated parameters."""
return ph.check_needs_rehash(hash)
# Usage
hash = hash_password("correct-horse-battery-staple")
print(f"Hash: {hash}")
# $argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$...
print(verify_password("correct-horse-battery-staple", hash)) # True
The OWASP recommendation for Argon2id in 2024 is: memory_cost=19456 (19 MB), time_cost=2, parallelism=1 as a minimum. Increase memory to 64MB or higher if your infrastructure allows.
Choosing the Right Algorithm
For new applications, use Argon2id. It’s the most thoroughly analyzed modern option with the best resistance to current attack hardware.
For existing applications using bcrypt, don’t migrate unless you have a specific reason. bcrypt remains secure. The engineering effort of migration is better spent elsewhere.
For legacy systems using MD5, SHA-1, or unsalted hashes, migrate immediately using an upgrade-on-login pattern:
import bcrypt
from argon2 import PasswordHasher
ph = PasswordHasher()
def verify_and_upgrade(user_id: int, password: str, stored_hash: str) -> bool:
"""Verify password and upgrade legacy hash if valid."""
# Detect hash type from format
if stored_hash.startswith('$argon2'):
# Already using Argon2
return ph.verify(stored_hash, password)
elif stored_hash.startswith('$2'):
# bcrypt hash - verify then upgrade
if bcrypt.checkpw(password.encode(), stored_hash.encode()):
new_hash = ph.hash(password)
update_user_hash(user_id, new_hash)
return True
return False
elif len(stored_hash) == 32:
# Likely MD5 - verify then upgrade
import hashlib
if hashlib.md5(password.encode()).hexdigest() == stored_hash:
new_hash = ph.hash(password)
update_user_hash(user_id, new_hash)
return True
return False
return False
def update_user_hash(user_id: int, new_hash: str):
"""Update user's password hash in database."""
# Your database update logic here
pass
Common Implementation Mistakes
Timing attacks in comparison: Never use == to compare hashes. String comparison typically short-circuits on the first mismatched character, leaking information about how much of the hash matched.
import hmac
import secrets
def constant_time_compare(a: bytes, b: bytes) -> bool:
"""Compare two byte strings in constant time."""
# hmac.compare_digest prevents timing attacks
return hmac.compare_digest(a, b)
# For string comparison
def safe_str_compare(a: str, b: str) -> bool:
return hmac.compare_digest(a.encode(), b.encode())
# Most password hashing libraries handle this internally,
# but verify your library's documentation
Insufficient work factors: The defaults in many libraries are outdated. Always explicitly set parameters based on current recommendations and your hardware benchmarks.
Logging or exposing hashes: Never log password hashes. Never return them in API responses. Treat them as sensitive data even though they’re not reversible.
Pepper misconceptions: A pepper is a secret key added to passwords before hashing. It provides defense-in-depth if your database leaks but your application secrets don’t. However, peppers complicate key rotation and provide marginal benefit if you’re already using strong hashing. If you implement a pepper, use HMAC rather than simple concatenation.
The bottom line: choose Argon2id for new systems, keep bcrypt for existing ones, tune your work factors aggressively, and verify your implementation handles comparison securely. Password hashing is a solved problem—the challenge is implementing the solution correctly.