Skip to content

Chapter 4 of 204 min readBy Deepak GuptaPublished

Password Hashing in 2026

Use Argon2id, store the full parameters with every hash, and upgrade hashes silently at sign-in. If compliance requires FIPS 140 validated cryptography, use PBKDF2-HMAC-SHA256 with at least 600,000 iterations instead.

Password hashing has one job: make each guess expensive for an attacker who has stolen your database, while keeping each sign-in cheap enough for your servers. Fast hashes such as MD5, SHA-1, and plain SHA-256 fail that job no matter how many times you salt them.

Recommended parameters

These are the OWASP Password Storage Cheat Sheet recommendations. Within each algorithm, the options are equivalent in strength; they trade memory against CPU. Benchmark on your own hardware before you choose. (OWASP)

Algorithm When to use Minimum parameters
Argon2id Default for new systems m=19 MiB, t=2, p=1 (or m=46 MiB, t=1, p=1; or m=12 MiB, t=3, p=1)
scrypt Argon2id unavailable N=2^17 (128 MiB), r=8, p=1 (or N=2^16, r=8, p=2)
bcrypt Legacy systems only Cost 10 minimum, higher if your servers allow; 72-byte input limit
PBKDF2-HMAC-SHA256 FIPS 140 required 600,000 iterations
PBKDF2-HMAC-SHA512 FIPS 140 required 220,000 iterations

Step by step: storing a password

  1. Receive the password over TLS. Never log request bodies on auth endpoints.
  2. Normalize Unicode to NFC so the same password typed on different devices produces the same bytes.
  3. Check length (Chapter 5) and screen it against breached-password data.
  4. Generate a random salt of at least 16 bytes from a cryptographically secure generator. Most libraries do this for you.
  5. Hash with Argon2id at your chosen parameters.
  6. Optionally apply a pepper (below).
  7. Store the result as a PHC string, which records algorithm, version, parameters, salt, and hash together:
$argon2id$v=19$m=19456,t=2,p=1$<salt-base64>$<hash-base64>

Step by step: verifying and upgrading

def verify_and_upgrade(user, password):
    stored = user.password_hash
    if not hasher.verify(stored, password):   # constant-time compare inside
        return False
    if hasher.needs_rehash(stored):            # old algorithm or weaker params
        user.password_hash = hasher.hash(password)
        save(user)
    return True

That needs_rehash check is how you raise parameters every year or two without a migration project. Users who sign in get upgraded automatically. Users who never sign in keep weak hashes, so set a policy for them: expire the password and require a reset after a defined period of inactivity.

Peppers

A pepper is a secret key shared across all hashes and stored outside the database, ideally in a KMS or HSM. The cleanest design is a post-hash pepper: stored = HMAC-SHA256(pepper, argon2id(password)), or encrypting the hash with a KMS key. A database dump alone is then useless for offline cracking. (OWASP)

Two cautions. A pepper adds defense in depth; it never replaces a slow hash. And if a pepper leaks, you cannot re-pepper without the plaintext, so rotation means versioned peppers plus rehash on login. Store a pepper version ID next to each hash from day one.

bcrypt traps

  • 72-byte limit. Most implementations silently ignore bytes after 72. Enforce a 72-byte maximum or pre-hash correctly.
  • Unsafe pre-hashing. bcrypt(sha512(password)) exposes you to "password shucking" and null-byte truncation. If you must pre-hash, use bcrypt(base64(HMAC-SHA-384(password, pepper))) with the pepper outside the database. (OWASP)

Migrating legacy hashes

If you inherit MD5, SHA-1, or unsalted hashes, wrap them now rather than waiting for users to sign in: argon2id(md5(password)), marked with a legacy flag. At the next sign-in, verify through the wrapper, then replace it with a direct Argon2id hash. This closes the exposure on day one instead of over years.

Capacity planning

Argon2id at 19 MiB uses 19 MiB of RAM per concurrent hash. Size verification capacity for peak sign-in rate times hash duration, plus headroom for credential-stuffing spikes. Run hashing in a separate worker pool so a login flood cannot starve the rest of the service (Chapter 15).

Warning

Common mistakes

  • Using a fast hash with a salt and calling it secure.
  • Hard-coding parameters with no record of them in the stored hash.
  • Logging passwords in request logs, error trackers, or analytics.
  • Comparing hashes with == instead of a constant-time comparison.

Key takeaways

  • Use Argon2id (m=19 MiB, t=2, p=1 minimum); PBKDF2-HMAC-SHA256 at 600,000 iterations when FIPS 140 is required.
  • Store a PHC string so every hash records its own algorithm and parameters.
  • Rehash on login to raise parameters without a migration project.
  • Wrap legacy MD5 or SHA-1 hashes on day one instead of waiting for sign-ins.