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)| Property | Salt | Pepper |
|---|---|---|
| Stored with the hash | Yes, in the same record | No, in a separate secret store |
| Secret | No | Yes |
| Unique per user | Yes | No, one per application |
| Stops precomputation | Yes | Only partially, since it is shared |
| Survives a database leak | No | Yes, if the pepper store is separate |
| Requires a rotation plan | No | Yes, 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
- Add a column recording the algorithm and parameters for each row, defaulting to the legacy scheme.
- Wrap verification so it dispatches to the correct algorithm per row.
- On each successful login, recompute the hash with the new algorithm and update the row.
- For rows that never log in, decide a policy: force a reset, or leave them on the legacy scheme with a deadline.
- Never log the plaintext password during the transition, and never email it.
- 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.
Related
Password hashing: bcrypt, scrypt and Argon2 Migrating off MD5 and SHA-1 safely
Last refreshed 2026-09-18.