Still using MD5 or SHA-256 to store passwords in your database? Modern GPUs can compute over 180 billion MD5 hashes per second. That means your users’ passwords could be cracked in minutes.
This guide compares the three password hashing algorithms recommended by security experts: bcrypt, Argon2, and scrypt. We’ll cover the pros and cons, real code examples, and how to choose the right algorithm for your use case.
1. Hashing vs Encryption: What’s the Difference?
Before diving into the algorithms, let’s clarify the difference between hashing and encryption. Confusing these two concepts can lead to critical security mistakes.
Encryption is a two-way process. You encrypt data with a key, and you can decrypt it later with the same key (or a corresponding key) to retrieve the original data.
Hashing is a one-way process. Once data is hashed, it cannot be reversed. This is why we use hashing for password storage—even if an attacker gains access to your database, they can’t recover the original passwords from the hash values alone.
| Aspect | Encryption | Hashing |
|---|---|---|
| Direction | Two-way (reversible) | One-way (irreversible) |
| Key Required | Yes | No (uses salt) |
| Primary Use | Data transmission, storage | Password verification, integrity checks |
| Example Algorithms | AES, RSA | bcrypt, Argon2, scrypt |
When storing passwords, always use hashing—not encryption. And not just any hash function, but one specifically designed for passwords.
2. Why MD5 and SHA-256 Are Unsuitable for Passwords
“Isn’t SHA-256 a secure algorithm?” Yes, SHA-256 is cryptographically secure. But the problem is that it’s too fast.
SHA-256 was designed for data integrity verification and digital signatures, where speed is an advantage. But for password hashing, speed works in the attacker’s favor.
Modern GPUs can compute billions of SHA-256 hashes per second. When attackers run rainbow table attacks or brute force attacks, a fast hash function makes their job easy.
That’s why password hashing algorithms are intentionally slow. A legitimate user waiting 200-500ms to log in is no big deal. But for an attacker trying millions of password combinations, that delay becomes a serious obstacle.
3. Salt and Pepper: The Essentials of Password Security
A critical concept in password hashing is the salt—a random string added to each password that ensures the same password produces different hash values for different users.
Consider two users who both use “password123” as their password. Without a salt, both would have identical hashes. Attackers can exploit this with rainbow table attacks.
But with a unique salt for each user, identical passwords produce completely different hashes. Fortunately, modern algorithms like bcrypt, Argon2, and scrypt automatically generate and manage salts, so developers don’t need to handle this manually.
A pepper is similar to a salt, but it’s a secret value applied to all passwords. Unlike salts, peppers are stored separately from the database (e.g., in environment variables or an HSM), providing an additional layer of protection if the database is compromised.
4. bcrypt – The Battle-Tested Veteran
bcrypt was developed in 1999 by Niels Provos and David Mazières. It’s the oldest and most widely used password hashing algorithm, adopted as the default password hash for OpenBSD and battle-tested for over 25 years.
How bcrypt Works
bcrypt leverages the key setup phase of the Blowfish cipher, which was designed to be computationally expensive. bcrypt exploits this property to create intentionally slow hashing.
Its key feature is an adjustable cost factor. Increasing this value exponentially increases hashing time, allowing you to maintain security as hardware improves.
Pros
- Proven reliability: 25+ years in production, passed numerous security audits
- Simple implementation: Library support in virtually every programming language
- Automatic salt handling: No manual salt management required
- Consistent performance: Fixed 4KB memory usage provides predictable resource consumption
Cons
- 72-byte password limit: Passwords longer than 72 bytes are truncated
- Fixed memory usage (4KB): Relatively vulnerable to modern GPU attacks
- No parallelism support: Cannot utilize multi-core CPUs
Using bcrypt in Python
import bcrypt
# Hash a password (work factor = 12, ~250ms)
password = b"MySecurePassword123!"
salt = bcrypt.gensalt(rounds=12) # rounds is the cost factor
hashed = bcrypt.hashpw(password, salt)
print(f"Hash: {hashed}")
# Example output: $2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.V...
# Verify password
if bcrypt.checkpw(password, hashed):
print("Password matches!")
else:
print("Password does not match.")
Install the library first:
pip install bcrypt
OWASP Recommendations
The OWASP Password Storage Cheat Sheet recommends:
- Cost factor (rounds): 10 or higher (preferably 12-14)
- Max password length: Limit to 72 bytes
- Use case: Legacy systems where Argon2 or scrypt aren’t available
5. scrypt – The Memory-Hard Pioneer
scrypt was developed in 2009 by Colin Percival while building Tarsnap, an encrypted backup service. He created scrypt to overcome the limitations of existing algorithms.
What Makes scrypt Special
scrypt’s key innovation is its memory-hard design. While bcrypt relies primarily on CPU computation, scrypt requires large amounts of memory.
Why does memory matter? Specialized hardware like GPUs and ASICs excel at parallel computation, but providing enough memory for each processing unit is difficult and expensive. scrypt exploits this limitation to effectively block hardware-based attacks.
scrypt Parameters
scrypt has three main parameters:
| Parameter | Description | Recommended Value |
|---|---|---|
| N (CPU/Memory Cost) | Memory and CPU cost, must be a power of 2 | 2^17 (131072) |
| r (Block Size) | Block size | 8 |
| p (Parallelism) | Degree of parallelism | 1 |
Pros
- Strong against GPU/ASIC attacks: Memory-hard design blocks hardware attacks
- Proven stability: 10+ years of extensive use in cryptocurrency mining
- Flexible parameters: Memory and CPU costs can be tuned independently
Cons
- High memory usage: Can be problematic in resource-constrained environments
- Cryptocurrency mining hardware exists: scrypt ASICs exist due to Litecoin mining
- More complex than bcrypt: Parameter tuning is more involved
Using scrypt in Python
Python 3.6+ includes scrypt in the standard library:
import hashlib
import os
import hmac
# Hash a password
password = b"MySecurePassword123!"
salt = os.urandom(16) # 16-byte random salt
# Generate scrypt hash
# n=2^14, r=8, p=1 is suitable for web applications
hashed = hashlib.scrypt(
password,
salt=salt,
n=16384, # 2^14, memory/CPU cost
r=8, # block size
p=1, # parallelism
dklen=32 # output length (32 bytes)
)
print(f"Salt: {salt.hex()}")
print(f"Hash: {hashed.hex()}")
# Store both salt and hash in your database
# Example: stored_data = salt.hex() + ":" + hashed.hex()
# Verification function
def verify_scrypt_password(password: bytes, stored_salt: bytes, stored_hash: bytes) -> bool:
"""Re-hash with stored salt and compare"""
new_hash = hashlib.scrypt(
password,
salt=stored_salt,
n=16384,
r=8,
p=1,
dklen=32
)
# Use constant-time comparison to prevent timing attacks
return hmac.compare_digest(new_hash, stored_hash)
# Verification example
is_valid = verify_scrypt_password(password, salt, hashed)
print(f"Valid: {is_valid}") # True
OWASP Recommendations
- N (CPU/Memory Cost): 2^17 (131072) or higher
- r (Block Size): 8
- p (Parallelism): 1
6. Argon2 – Winner of the 2015 Password Hashing Competition
Argon2 won the Password Hashing Competition (PHC) in 2015. Developed by Alex Biryukov, Daniel Dinu, and Dmitry Khovratovich at the University of Luxembourg, it’s currently OWASP’s top recommendation for password hashing.
The Three Variants of Argon2
Argon2 comes in three variants:
| Variant | Characteristics | Use Case |
|---|---|---|
| Argon2d | Data-dependent memory access, strong against GPU attacks | Cryptocurrency, offline environments |
| Argon2i | Data-independent memory access, strong against side-channel attacks | Sensitive server environments |
| Argon2id | Hybrid of Argon2d and Argon2i (recommended) | General password storage |
Argon2id operates as Argon2i for the first half and Argon2d for the second half, providing protection against both side-channel and GPU attacks. RFC 9106 also recommends Argon2id.
Argon2 Parameters
Argon2 offers four main parameters:
| Parameter | Description | OWASP Recommended |
|---|---|---|
| m (Memory) | Memory to use (KB) | 19456 (19MB) or 47104 (46MB) |
| t (Iterations) | Number of iterations | 2 (with 19MB) or 1 (with 46MB) |
| p (Parallelism) | Number of parallel threads | 1 |
| hash length | Output hash length | 32 bytes |
Pros
- Modern design: Considers GPU, ASIC, and side-channel attacks
- High flexibility: Memory, time, and parallelism can be tuned independently
- Future-proof: Parameters can scale with hardware improvements
- RFC standardized: Officially standardized as RFC 9106
Cons
- Relatively new: Introduced in 2015, less field experience than bcrypt
- Higher resource requirements: Requires careful parameter tuning for web applications
- Limited library support in some environments: Older systems may lack support
Using Argon2 in Python
Install the argon2-cffi library:
pip install argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
# Create PasswordHasher with default settings
# (defaults: time_cost=3, memory_cost=65536, parallelism=4)
ph = PasswordHasher()
# Hash a password
password = "MySecurePassword123!"
hashed = ph.hash(password)
print(f"Hash: {hashed}")
# Example output: $argon2id$v=19$m=65536,t=3,p=4$...
# Verify password
try:
ph.verify(hashed, password)
print("Password matches!")
except VerifyMismatchError:
print("Password does not match.")
# Customize with OWASP recommended settings
ph_owasp = PasswordHasher(
time_cost=2, # iterations
memory_cost=19456, # memory (KB), ~19MB
parallelism=1, # parallel threads
hash_len=32, # hash length
salt_len=16 # salt length
)
hashed_owasp = ph_owasp.hash(password)
print(f"OWASP config hash: {hashed_owasp}")
Understanding the Argon2 Hash Format
Argon2 hashes follow this format:
$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$SqlVijFGiPG...
argon2id: The Argon2 variant usedv=19: Argon2 version (1.3)m=19456: Memory cost (KB)t=2: Iteration countp=1: Parallelism- First Base64 segment: Salt
- Second Base64 segment: Hash value
7. bcrypt vs Argon2 vs scrypt – Comparison Table
Here’s a side-by-side comparison. (Note: PBKDF2 is only recommended for FIPS compliance and is covered separately.)
| Aspect | bcrypt | scrypt | Argon2 (Argon2id) |
|---|---|---|---|
| Released | 1999 | 2009 | 2015 |
| Based On | Blowfish cipher | PBKDF2-HMAC-SHA256 | Blake2b hash |
| Memory Usage | Fixed 4KB | Configurable (MB to GB) | Configurable (MB to GB) |
| GPU Attack Resistance | Medium | High | Very High |
| Side-Channel Resistance | High | Low | High (Argon2id) |
| Password Limit | 72 bytes | None | None |
| Library Support | Extensive | Wide | Growing |
| OWASP Priority | 3rd | 2nd | 1st |
| Hash Time Example | ~250ms | ~200ms | ~150ms |
Performance Comparison (Equivalent Security Level)
Real-world server performance comparison:
| Algorithm | Configuration | Hash Time | Memory Usage |
|---|---|---|---|
| bcrypt | cost=13 | 250-350ms | 4KB |
| scrypt | N=2^17, r=8, p=1 | 180-300ms | 128MB |
| Argon2id | m=128MB, t=3, p=2 | 220-280ms | 128MB |
Notice the significant difference in memory usage. bcrypt uses only 4KB, while scrypt and Argon2 can use hundreds of MB. This memory requirement is what makes GPU attacks difficult.
8. Which Algorithm Should You Choose?
Here’s a practical guide for different scenarios.
New Project? → Argon2id
For greenfield projects, choose Argon2id. It’s the latest algorithm recommended by OWASP, IETF (RFC 9106), and other security authorities.
# Recommended settings for new projects
from argon2 import PasswordHasher
ph = PasswordHasher(
time_cost=2,
memory_cost=19456, # 19MB
parallelism=1
)
Legacy System? → bcrypt
If you’re already using bcrypt, there’s no urgent need to migrate. bcrypt remains secure—just keep the cost factor at 12 or higher.
However, plan for a gradual migration to Argon2 in the long term.
FIPS Compliance Required? → PBKDF2
For banks, government agencies, or any environment requiring FIPS-140 compliance, use PBKDF2. OWASP recommends 600,000+ iterations with HMAC-SHA-256.
Resource-Constrained Environment? → bcrypt
For low-powered servers or IoT devices with limited memory, bcrypt is a good fit. It provides solid security with only 4KB of fixed memory usage.
Cryptocurrency/Blockchain? → scrypt or Argon2d
For cryptocurrency projects requiring key derivation, scrypt‘s extensive field experience can be valuable. In environments without side-channel attack concerns, Argon2d is also a strong choice.
9. Security Best Practices for Implementation
Choosing the right algorithm is only half the battle. Proper implementation is equally important.
What NOT to Do
# ❌ Wrong: Using a static salt
static_salt = b"fixed_salt_value" # Dangerous!
# ❌ Wrong: Low cost factor
bcrypt.hashpw(password, bcrypt.gensalt(rounds=4)) # Too low!
# ❌ Wrong: Using a general-purpose hash function
import hashlib
hashlib.sha256(password.encode()).hexdigest() # Not for passwords!
What to Do
# ✅ Correct: Use library-generated random salts
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
# ✅ Correct: Use constant-time comparison (prevents timing attacks)
import hmac
def safe_compare(a, b):
return hmac.compare_digest(a, b)
# ✅ Correct: Use appropriate cost factors
from argon2 import PasswordHasher
ph = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
Regular Parameter Review
Hardware performance improves every year. OWASP recommends reviewing and increasing parameters annually.
General guidelines:
- Argon2: Increase iterations (t) by 1
- bcrypt: Increase cost factor by 1
- scrypt: Double the N value
- PBKDF2: Double the iteration count
10. Migrating Hashing Algorithms in Production
How do you change hashing algorithms in a live system? Since passwords can’t be reversed, you can’t convert them all at once. Instead, use a gradual migration approach.
Gradual Migration Strategy
- Adopt the new algorithm: Apply Argon2id to new user registrations
- Upgrade on login: When existing users log in, verify with the old algorithm, then re-hash with the new one
- Version tracking: Include version information in hashes to identify which algorithm was used
import bcrypt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
def verify_and_upgrade(password: str, stored_hash: str) -> tuple[bool, str | None]:
"""
Verify password and upgrade to Argon2 if needed.
Returns: (verification_success, new_hash or None)
"""
new_hash = None
# Check stored hash format
if stored_hash.startswith("$argon2"):
# Already using Argon2
ph = PasswordHasher()
try:
ph.verify(stored_hash, password)
return True, None
except VerifyMismatchError:
return False, None
elif stored_hash.startswith("$2"):
# bcrypt hash - verify and upgrade
if bcrypt.checkpw(password.encode(), stored_hash.encode()):
# Verification successful, upgrade to Argon2
ph = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
new_hash = ph.hash(password)
return True, new_hash
return False, None
return False, None
# Usage example
success, new_hash = verify_and_upgrade("user_password", stored_hash_from_db)
if success:
if new_hash:
# Save new hash to database
update_user_hash(user_id, new_hash)
# Process successful login
11. Password Hashing in Node.js
For those working with Node.js, here are implementation examples for each algorithm.
bcrypt (Node.js)
const bcrypt = require('bcrypt');
// Hash
async function hashPassword(password) {
const saltRounds = 12;
const hash = await bcrypt.hash(password, saltRounds);
return hash;
}
// Verify
async function verifyPassword(password, hash) {
const match = await bcrypt.compare(password, hash);
return match;
}
// Usage
(async () => {
const hash = await hashPassword('MySecurePassword123!');
console.log('Hash:', hash);
const isValid = await verifyPassword('MySecurePassword123!', hash);
console.log('Valid:', isValid); // true
})();
Argon2 (Node.js)
const argon2 = require('argon2');
// Hash (OWASP recommended settings)
async function hashPassword(password) {
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 19456, // 19MB
timeCost: 2,
parallelism: 1
});
return hash;
}
// Verify
async function verifyPassword(password, hash) {
try {
return await argon2.verify(hash, password);
} catch (err) {
return false;
}
}
// Usage
(async () => {
const hash = await hashPassword('MySecurePassword123!');
console.log('Hash:', hash);
const isValid = await verifyPassword('MySecurePassword123!', hash);
console.log('Valid:', isValid); // true
})();
scrypt (Node.js)
Node.js includes scrypt in the built-in crypto module:
const crypto = require('crypto');
// Hash
async function hashPassword(password) {
return new Promise((resolve, reject) => {
const salt = crypto.randomBytes(16);
crypto.scrypt(password, salt, 32, { N: 16384, r: 8, p: 1 }, (err, derivedKey) => {
if (err) reject(err);
// Store salt and hash together (colon-separated)
resolve(salt.toString('hex') + ':' + derivedKey.toString('hex'));
});
});
}
// Verify
async function verifyPassword(password, stored) {
return new Promise((resolve, reject) => {
const [saltHex, hashHex] = stored.split(':');
const salt = Buffer.from(saltHex, 'hex');
const storedHash = Buffer.from(hashHex, 'hex');
crypto.scrypt(password, salt, 32, { N: 16384, r: 8, p: 1 }, (err, derivedKey) => {
if (err) reject(err);
// Use timingSafeEqual to prevent timing attacks
resolve(crypto.timingSafeEqual(storedHash, derivedKey));
});
});
}
// Usage
(async () => {
const stored = await hashPassword('MySecurePassword123!');
console.log('Stored:', stored);
const isValid = await verifyPassword('MySecurePassword123!', stored);
console.log('Valid:', isValid); // true
})();
12. Summary
Choosing a password hashing algorithm directly impacts your users’ security. Here’s what to remember:
Algorithm Priority (OWASP Recommendation)
- Argon2id – First choice for new projects
- scrypt – When Argon2 isn’t available
- bcrypt – Legacy systems or resource-constrained environments
- PBKDF2 – Only when FIPS compliance is required
Key Takeaways
- Never use general-purpose hash functions (MD5, SHA-256) for passwords
- Salts must be unique per password—modern algorithms handle this automatically
- Configure cost factors appropriately for your server capacity, but don’t set them too low
- Review and increase parameters annually as hardware improves
Password security isn’t a one-time setup—it requires ongoing maintenance and improvement.
References
- OWASP Password Storage Cheat Sheet
- RFC 9106 – Argon2 Memory-Hard Function
- Argon2 Reference Implementation (GitHub)
- bcrypt Python Library (GitHub)
- argon2-cffi Documentation