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 used
  • v=19: Argon2 version (1.3)
  • m=19456: Memory cost (KB)
  • t=2: Iteration count
  • p=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

  1. Adopt the new algorithm: Apply Argon2id to new user registrations
  2. Upgrade on login: When existing users log in, verify with the old algorithm, then re-hash with the new one
  3. 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)

  1. Argon2id – First choice for new projects
  2. scrypt – When Argon2 isn’t available
  3. bcrypt – Legacy systems or resource-constrained environments
  4. 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

 

 

 

Leave a Reply