Secure Password Storage

Bcrypt, Argon2, PBKDF2, Salts, Peppers & Password Policies

Introduction

Storing passwords is one of the most common, and most frequently mishandled, security tasks. When a database is breached (and it will be), your password storage strategy is the last line of defense protecting your users. This lesson shows you exactly why common approaches fail and what to use instead, with working Python examples for every major algorithm.

Why Common Approaches Fail

Wrong Approaches
  • Plain text, Any breach exposes every password immediately
  • MD5/SHA-1, GPU can crack billions per second; precomputed rainbow tables
  • Plain SHA-256, Still too fast: 10 billion+ attempts/sec on modern GPUs
  • Unsalted hashes, Identical passwords produce identical hashes; one rainbow table cracks all
  • Symmetric encryption, Key must be stored somewhere; if breached, all passwords exposed at once
Correct Approaches
  • bcrypt, Adaptive cost factor; 60+ years to crack with GPU clusters at cost=14
  • Argon2id, Winner of 2015 Password Hashing Competition; memory-hard algorithm
  • PBKDF2-SHA256, NIST-recommended; 600,000+ iterations in 2024 guidance
  • Automatic salting, All three above generate unique salts per password automatically
  • Pepper, Application-level secret added before hashing for extra protection
What is a Rainbow Table?

A rainbow table is a precomputed lookup table mapping common passwords to their hash values. Without salts, an attacker who steals your database can look up MD5("password") → "password123" in milliseconds. Salts make rainbow tables useless because they force the attacker to crack each hash individually. Slow hashing algorithms (bcrypt/Argon2) make each individual crack attempt take significant time, even with GPUs.

bcrypt

bcrypt has been the industry standard for password hashing since 1999. The cost factor makes it adaptive: as hardware gets faster, increase the cost to keep cracking time constant.

Install: pip install bcrypt

# bcrypt_demo.py
import bcrypt
import time

def hash_password(password: str, cost: int = 12) -> bytes:
    """Hash a password with bcrypt. Returns bytes including salt."""
    salt = bcrypt.gensalt(rounds=cost)
    return bcrypt.hashpw(password.encode("utf-8"), salt)

def verify_password(password: str, hashed: bytes) -> bool:
    """Verify a password against a bcrypt hash."""
    return bcrypt.checkpw(password.encode("utf-8"), hashed)

# Hash a password
password = "MyS3cur3P@ssw0rd!"
start = time.perf_counter()
hashed = hash_password(password, cost=12)
elapsed = time.perf_counter() - start

print(f"Password: {password}")
print(f"Hash:     {hashed.decode()}")
print(f"Time:     {elapsed:.3f}s at cost=12")
print(f"Hash prefix: $2b$12$ = bcrypt v2b, cost 12")

# Verify correct password
is_valid = verify_password(password, hashed)
print(f"\nValid password: {is_valid}")   # True

# Verify wrong password
is_valid_wrong = verify_password("wrong_password", hashed)
print(f"Wrong password: {is_valid_wrong}")   # False

# Show that two hashes of the same password differ (different salts)
hash2 = hash_password(password, cost=12)
print(f"\nSame password, different hashes (different salts):")
print(f"  Hash 1: {hashed.decode()[:29]}...")
print(f"  Hash 2: {hash2.decode()[:29]}...")
print(f"  Equal?  {hashed == hash2}")   # False
Expected Output:
Password: MyS3cur3P@ssw0rd!
Hash:     $2b$12$EUKm5JE1VunSnA8uAYP1Qer97e7ChYUrnJu6Nmu7x.dhQ1D3I7ZHy
Time:     0.190s at cost=12
Hash prefix: $2b$12$ = bcrypt v2b, cost 12

Valid password: True
Wrong password: False

Same password, different hashes (different salts):
  Hash 1: $2b$12$EUKm5JE1VunSnA8uAYP1Qe...
  Hash 2: $2b$12$5NYrwfsQXxX95oV6uh.Ydu...
  Equal?  False

Argon2, The Modern Standard

Argon2id is the winner of the 2015 Password Hashing Competition and is now recommended by OWASP as the top choice. It is memory-hard, it requires a configurable amount of RAM to compute, making it much more expensive to attack with GPUs or ASICs.

Install: pip install argon2-cffi

# argon2_demo.py
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError

# OWASP-recommended Argon2id parameters (2024)
ph = PasswordHasher(
    time_cost=2,        # Number of iterations
    memory_cost=65536,  # 64 MB of RAM required
    parallelism=1,      # Threads
    hash_len=32,        # Output length in bytes
    salt_len=16,        # Salt length in bytes
)

password = "AnotherS3cur3P@ss!"

# Hash
hash_value = ph.hash(password)
print(f"Argon2id hash:")
print(f"  {hash_value}")

# Verify
try:
    ph.verify(hash_value, password)
    print("\nPassword verification: PASSED ✅")
except VerifyMismatchError:
    print("\nPassword verification: FAILED ❌")

# Argon2 supports rehashing if parameters change
needs_rehash = ph.check_needs_rehash(hash_value)
print(f"Needs rehash: {needs_rehash}")  # False (just generated)

# Wrong password
try:
    ph.verify(hash_value, "wrong_password")
except VerifyMismatchError:
    print("Wrong password rejected ✅")
Expected Output:
Argon2id hash:
  $argon2id$v=19$m=65536,t=2,p=1$oCBKeW1Z5IsdCOiXVvyL/A$hmEeabf1GiOH6pxZMmP9j/finHnpIJAg5nNHjiCGMcY

Password verification: PASSED ✅
Needs rehash: False
Wrong password rejected ✅

PBKDF2 & the Pepper Strategy

# pbkdf2_pepper_demo.py
import hashlib
import hmac
import os
import secrets

# PEPPER: application-level secret, stored in environment / secrets manager
# NOT stored in the database, different from the salt
PEPPER = os.environ.get("PASSWORD_PEPPER", secrets.token_hex(32))

def hash_password_pbkdf2(password: str) -> tuple[bytes, bytes]:
    """
    Hash password with PBKDF2-SHA256 + pepper + automatic salt.
    Returns (salt, hash), store both in the database.
    """
    # Apply pepper before hashing (HMAC prevents length extension)
    peppered = hmac.new(PEPPER.encode(), password.encode(), hashlib.sha256).digest()

    salt = secrets.token_bytes(32)   # 256-bit random salt
    iterations = 600_000             # NIST SP 800-63B 2024 recommendation

    key = hashlib.pbkdf2_hmac(
        "sha256",
        peppered,
        salt,
        iterations,
        dklen=32,
    )
    return salt, key

def verify_password_pbkdf2(password: str, salt: bytes, stored_hash: bytes) -> bool:
    """Verify a PBKDF2-hashed password."""
    peppered = hmac.new(PEPPER.encode(), password.encode(), hashlib.sha256).digest()
    key = hashlib.pbkdf2_hmac("sha256", peppered, salt, 600_000, dklen=32)
    return hmac.compare_digest(key, stored_hash)

# Usage
password = "SecretP@ss2024!"
salt, pw_hash = hash_password_pbkdf2(password)

print(f"Salt (hex):  {salt.hex()[:32]}...")
print(f"Hash (hex):  {pw_hash.hex()}")
print(f"\nVerification correct: {verify_password_pbkdf2(password, salt, pw_hash)}")
print(f"Verification wrong:   {verify_password_pbkdf2('wrongpass', salt, pw_hash)}")
print("\n✅ Pepper stored in environment variable, not in DB")
print("✅ Salt stored alongside hash in DB")
print("✅ 600,000 PBKDF2 iterations (NIST 2024 recommendation)")
Expected Output:
Salt (hex):  b78d42bff5dbacf2f5218df20ef78803...
Hash (hex):  b3fb41a6f9988021669c17261e0cdc7ad38a2a60f091ff5cba5ec0ec6c3ba6f2

Verification correct: True
Verification wrong:   False

✅ Pepper stored in environment variable, not in DB
✅ Salt stored alongside hash in DB
✅ 600,000 PBKDF2 iterations (NIST 2024 recommendation)
AlgorithmOWASP RankMemory Hard?GPU Resistant?Recommendation
Argon2id1st ⭐✅ Yes✅ ExcellentUse for new applications
bcrypt2ndPartially✅ GoodUse for legacy apps; well-tested
PBKDF2-SHA2563rd❌ No⚠️ ModerateFIPS-compliant environments
SHA-256 (plain)N/A❌ No❌ NoneNever for passwords

Key Takeaways

  • Never store plain text, MD5, or unsalted hashes, these are all equivalent to plain text in practice
  • Argon2id is the top recommendation, memory-hard, GPU-resistant, PHC winner
  • bcrypt remains excellent, 25+ years of battle-testing; preferred for legacy compatibility
  • PBKDF2 with 600,000+ iterations, acceptable when FIPS compliance is required
  • Salts are automatic, bcrypt, Argon2, PBKDF2 all generate unique salts internally
  • Pepper adds a second factor, even if the database is stolen, passwords require the application secret too
  • Use hmac.compare_digest(), always compare hashes in constant time to prevent timing attacks
What's Next?

Lesson 4 goes beyond passwords to full encryption in practice, encrypting application data, files, and API payloads with AES-GCM, RSA, and hybrid encryption schemes.

  • AES-GCM, Authenticated encryption for application data
  • RSA in practice, Encrypting symmetric keys with RSA
  • Hybrid encryption, Combining RSA + AES for real-world use