Salts, peppers and work factors

What a per-user salt actually prevents, where a pepper is stored, why comparison must be constant time, and how to migrate a legacy password store safely.

A salt is not a secret

A salt is a unique random value per stored password. Its job is to make precomputation useless and to stop two users with the same password from producing the same hash. It is stored next to the hash and makes no attempt to be secret.

import hashlib, secrets

def hash_password(password: str) -> str:
    salt = secrets.token_bytes(16)                    # cryptographically random
    dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 600_000)
    return f"pbkdf2_sha256$600000${salt.hex()}${dk.hex()}"

def verify(stored: str, password: str) -> bool:
    algo, iters, salt_hex, dk_hex = stored.split("$")
    dk = hashlib.pbkdf2_hmac(algo_to_hash(algo), password.encode(),
                             bytes.fromhex(salt_hex), int(iters))
    return secrets.compare_digest(dk.hex(), dk_hex)
PropertySaltPepper
Stored with the hashYes, in the same recordNo, in a separate secret store
SecretNoYes
Unique per userYesNo, one per application
Stops precomputationYesOnly partially, since it is shared
Survives a database leakNoYes, if the pepper store is separate
Requires a rotation planNoYes, rotating it rehashes everything

Choosing and raising a work factor

import time, hashlib, secrets

def measure(iterations):
    salt = secrets.token_bytes(16)
    t0 = time.perf_counter()
    hashlib.pbkdf2_hmac("sha256", b"password", salt, iterations)
    return time.perf_counter() - t0

for n in (100_000, 300_000, 600_000, 1_200_000):
    print(n, f"{measure(n) * 1000:.1f} ms")
  • Target a verification time you can afford under peak login load, not the slowest number you can find.
  • Measure on production-class hardware; a laptop with a fast single core will understate the cost badly.
  • Halving the work factor doubles the attacker's throughput, so small-looking changes matter.
  • Store the parameters in the hash string so old and new values can coexist during a rollout.
  • A shared pepper that leaks is worse than no pepper if it was the only compensating control.

Migrating a legacy store

  1. Add a column recording the algorithm and parameters for each row, defaulting to the legacy scheme.
  2. Wrap verification so it dispatches to the correct algorithm per row.
  3. On each successful login, recompute the hash with the new algorithm and update the row.
  4. For rows that never log in, decide a policy: force a reset, or leave them on the legacy scheme with a deadline.
  5. Never log the plaintext password during the transition, and never email it.
  6. Track progress by counting rows still on the legacy scheme, and alert if the count stops falling.
def verify_and_upgrade(user, password):
    scheme = user.hash_scheme               # "md5", "pbkdf2", "argon2id"
    if scheme == "md5":
        if not legacy_md5_verify(user.password_hash, password):
            return False
        user.password_hash = argon2_hash(password)     # upgrade in place
        user.hash_scheme = "argon2id"
        db.save(user)
        return True
    return argon2_verify(user.password_hash, password)
💡
Migration on login only works for users who actually log in. Long-dormant accounts stay on the old scheme indefinitely, so pair the upgrade with an expiry or a forced reset at a date you commit to publicly in your security policy.

FAQ

Can I use one salt for all users?
No. A shared salt means identical passwords produce identical hashes, so one crack reveals every account with that password and rainbow tables become viable again.
Is a pepper worth the complexity?
Yes when you can store it outside the database, for example in a secrets manager or an environment variable. It converts a database dump alone into a useless artifact.

Password hashing: bcrypt, scrypt and Argon2 Migrating off MD5 and SHA-1 safely

Last refreshed 2026-09-18.