Skip to content
d.devtul.fun
中文
Security · 2026-09-11

Password Hashing Done Right: A Developer's Guide to bcrypt, scrypt, and Argon2

If your user table stores md5(password) or sha256(password), stop reading and start planning a migration. You are not storing passwords securely; you are storing them in a form a thief can reverse in minutes. This is the most common, most preventable crypto mistake in production code, and it is worth being blunt about it.

Why fast hashes are the wrong tool

A GPU does not think about your password. It just hammers candidates. A single modern GPU can compute tens of billions of SHA-256 hashes per second. An attacker who steals your table is not decrypting anything — they are guessing, and at that speed "Tr0ub4dor&3" lasts about as long as it takes to read it.

Rainbow tables make it worse. These are precomputed tables mapping hashes to passwords. Without a salt, an attacker hashes "password123" once, builds the table once, and reuses it against every database on the internet. Salting defeats this by forcing a unique, per-user table — but only if the hash is also slow enough that building even one table is painful.

What salt fixes, and what it does not

A salt is random bytes stored next to the hash. It ensures two users with the same password get different digests, and it forces attackers to attack each hash individually rather than with one shared table. Salt is necessary. It is not sufficient on its own, because if the underlying hash is fast, individual attacks are still trivially cheap.

That is the missing piece: you need the hash to be slow and, ideally, memory-hungry, so that each guess costs the attacker real time and real RAM. That is the entire design goal of bcrypt, scrypt and Argon2.

bcrypt: the old reliable

bcrypt has been the default recommendation for over a decade. It has one notorious foot-gun:

import bcrypt

pw = b"correct horse battery staple"
# bcrypt silently truncates inputs at 72 bytes!
hashed = bcrypt.hashpw(pw, bcrypt.gensalt(rounds=12))
print(hashed)   # $2b$12$...  — the cost factor is baked in

The 72-byte truncation means anything past the 72nd byte is ignored. If your users can set very long passwords, an attacker only needs to brute-force the first 72 bytes. The fix is to pre-hash very long inputs or, more simply, just enforce a sane maximum length and document the limit. The rounds parameter (the cost factor) is what makes it slow; 12 is a reasonable 2026 default, and you should raise it as hardware gets faster.

scrypt: adding memory hardness

scrypt raised the cost by demanding large amounts of memory, not just CPU time. That hurts GPU and ASIC attackers, who are great at parallel computation but bad at cheaply providing gigabytes of fast memory per guess.

import hashlib
import os

salt = os.urandom(16)
dk = hashlib.scrypt(
    b"correct horse battery staple",
    salt=salt,
    n=2**14,    # CPU/memory cost
    r=8,
    p=1,
    dklen=32,
)
# store salt + dk together, e.g. n:r:p:salt:dk

The n parameter is the dominant cost knob. Larger n means exponentially more memory and time. Pick the largest value your servers can tolerate at login time.

Argon2id: the current best practice

Argon2 won the Password Hashing Competition in 2015, and its Argon2id variant (resistant to both GPU and side-channel attacks) is now the top recommendation. It tunes three independent knobs:

ParameterMeaningOWASP 2023 default
time_cost (t)Number of passes2
memory_cost (m)Memory in KiB19456 (≈19 MiB)
parallelism (p)Threads per hash1
from argon2 import PasswordHasher

ph = PasswordHasher(
    time_cost=2,
    memory_cost=19456,
    parallelism=1,
    hash_len=32,
    type=argon2.Type.ID,
)
stored = ph.hash("correct horse battery staple")
# later, on login:
try:
    ph.verify(stored, "correct horse battery staple")
except argon2.exceptions.VerifyMismatchError:
    # reject
    pass

# re-hash if the configured parameters changed:
if ph.check_needs_rehash(stored):
    stored = ph.hash(password)

The library encodes the parameters inside the stored string, so you can later raise the cost and transparently re-hash on the next login via check_needs_rehash.

Password hashing is not key derivation

PBKDF2 deserves a mention but sits in a different category. It is a key derivation function: it stretches a password into a cryptographic key for further use (say, encrypting a file). It uses many iterations of a hash to slow things down, but it is not memory-hard, so it ages worse than scrypt or Argon2 against GPU farms. For storing login passwords, Argon2id or bcrypt is the better pick; for deriving an encryption key from a passphrase, PBKDF2, scrypt or Argon2 all apply depending on your threat model.

Migrating an old hash format

You cannot rehash existing passwords in bulk because you do not have the plaintext. Upgrade lazily on each successful login, exactly as described in the MD5 article:

def verify_and_migrate(user, raw):
    stored = user.password_hash
    if stored.startswith("$legacy$"):
        if legacy_md5_check(raw, stored):
            user.password_hash = bcrypt.hashpw(
                raw.encode(), bcrypt.gensalt(rounds=12)
            )
            user.save()
            return True
        return False
    return bcrypt.checkpw(raw.encode(), stored)

Active users upgrade within a session; dormant ones get forced to reset. No plaintext ever has to be stored or transmitted.

A mistake you will see in the wild

If you audit enough codebases, you will find password storage that looks "hashed" and is therefore assumed safe. It is not:

# wrong: fast, unsalted, GPU-crackable in seconds
import hashlib
stored = hashlib.sha256(password).hexdigest()

# right: slow, salted by the library, designed to resist GPUs
import bcrypt
stored = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))

The first line fails on three counts at once: it is fast, it has no per-user salt, and it is not memory-hard. The second line fixes all three by delegating to a purpose-built password hash. There is no clever reason to prefer the first form; it is simply a bug wearing a "hash" label.

The practical checklist

  • Never use MD5, SHA-1, or plain SHA-256 for password storage.
  • Use Argon2id (preferred) or bcrypt; consider scrypt if memory hardness matters most.
  • Let the library handle salting — do not invent your own.
  • Watch bcrypt's 72-byte truncation if you allow long passwords.
  • Tune the cost so a login takes, say, 100–300 ms on your hardware, then revisit it yearly.
  • Migrate legacy hashes on login, not in a risky bulk job.

Pick a library, set sane parameters, and stop hand-rolling. The cryptographic primitives are solved; the only remaining bug is usually the one in your own code.

Keep reading