Password Hashing Done Right: Salts, Peppers, and KDFs
A practical guide to storing passwords securely in 2026 — why general hashes fail, salts vs peppers, work factors, and choosing Argon2id, scrypt, bcrypt or PBKDF2.
Storing passwords is one of the few security decisions that almost every application has to make, and one of the easiest to get subtly wrong. The advice here is opinionated but mainstream: use a purpose-built password hashing function, salt every credential, and tune the work factor so verification is comfortably slow. This guide explains why each of those rules exists, so you can make defensible choices rather than copying a snippet.
Why a fast cryptographic hash is the wrong tool
The instinct to reach for SHA-256 is understandable — it is a cryptographic hash, it is one-way, and it produces a fixed-size digest. But the very property that makes SHA-256 excellent for integrity checks makes it dangerous for passwords: it is fast. A modern GPU can compute billions of SHA-256 hashes per second. MD5 is worse still — both broken for collision resistance and trivially fast. To understand the mechanics behind these functions, see how hashing works.
Speed is the attacker's friend. When a credential database leaks, the attacker runs offline guessing against the stolen hashes. The faster the hash, the more candidate passwords they can test per second. A fast hash turns a leaked database into a few hours of GPU time against any password that is not extremely long and random.
There is a second, independent failure mode: unsalted hashes. If two users share the password hunter2, an unsalted SHA-256 produces the identical digest for both. Worse, attackers precompute enormous lookup tables — rainbow tables — mapping common passwords to their hashes. Against an unsalted database, cracking degenerates into a table lookup. No guessing required.
Salts: per-user, random, and not secret
A salt is a random value combined with the password before hashing. It does not need to be secret; it is stored right next to the hash in the database. Its job is to make every stored hash unique even when the underlying passwords are identical.
Salting correctly means each credential gets its own salt, generated from a cryptographically secure random source, with enough length (16 bytes is a common, safe choice). Get this right and two things follow:
- Rainbow tables become useless. A precomputed table would have to be rebuilt for every distinct salt, which defeats the entire economic premise of precomputation.
- Identical passwords no longer collide. Two users with the same password get different stored hashes, so a single crack does not reveal every account sharing that password.
The classic mistakes are using a single global salt (back to shared hashes for shared passwords), reusing salts across users, or deriving the salt from the username (predictable salts let attackers precompute per-target). The fix is simple: random and unique per credential, every time.
Peppers: a secret the database never sees
A pepper is also a secret value mixed into the hashing process, but unlike a salt it is not stored with the hash. It lives outside the database — in application configuration, a secrets manager, or ideally a hardware security module (HSM). The same pepper is typically shared across all users.
The threat model is specific: a pepper defends against a database-only breach. If an attacker dumps your password table but never obtains the application secret, every hash is computed with an unknown key, and offline cracking is impossible without first recovering the pepper. If the attacker gets both the database and the application config, the pepper adds nothing — so its value depends entirely on keeping the two compartments separate.
The cleanest way to apply a pepper is to keep it out of the KDF input and instead run an HMAC over the password with the pepper as the key, then feed the result into your password hashing function (or HMAC the stored hash). This keeps the secret in a real keyed primitive and makes pepper rotation tractable: you can version peppers and re-pepper on next login. Peppers are a defense-in-depth bonus, not a substitute for a strong KDF and per-user salts.
Work factors and adaptive cost
The defining feature of a password hashing function is a tunable work factor — a cost parameter that controls how much computation (and, for memory-hard functions, how much RAM) each hash requires. You set it so that a single legitimate verification takes a small but noticeable amount of time, often quoted around 250 ms to 500 ms on your auth servers.
This is deliberate friction. It is negligible for one login but devastating for an attacker testing billions of guesses. Crucially, the cost is adaptive: as hardware gets faster, you raise the parameter. Choose values you can revisit — and have a plan to increase them — because a setting that was strong five years ago may be weak today. The ability to migrate to higher cost over time is part of the design, not an afterthought.
The right tools: slow, purpose-built KDFs
Use a key derivation function built for passwords. Four mainstream choices:
Argon2id — the recommended default
Argon2id won the Password Hashing Competition and is the current default recommendation for new systems. It is memory-hard: it forces the attacker to commit large amounts of RAM per guess, which neutralizes cheap GPU and ASIC parallelism. The id variant blends data-dependent and data-independent memory access to resist both side-channel and time-memory tradeoff attacks. You tune three knobs — memory size, iterations (time cost), and parallelism. Start from a memory cost in the tens of MiB and raise it until verification hits your latency budget, prioritizing memory over iterations.
scrypt — memory-hard alternative
scrypt predates Argon2 and is also memory-hard, with a long track record (notably in cryptocurrency and disk encryption). Its N, r, and p parameters control memory and CPU cost. It is a perfectly reasonable choice where it is already available and Argon2 is not, though Argon2id is generally preferred for greenfield work.
bcrypt — battle-tested
bcrypt has been deployed for decades and remains a solid, conservative option. Its single cost (work factor) parameter doubles the work each time you increment it. It is not meaningfully memory-hard, so it offers less protection against GPU attacks than the memory-hard functions, but its maturity and ubiquity make it a safe pick. Mind its input length limit (below).
PBKDF2 — compliance and interop
PBKDF2 is the oldest of the four and the weakest against parallel hardware because it is neither memory-hard nor branch-heavy — it just iterates an HMAC. Its enduring relevance is compliance and interoperability: it is FIPS-validated and available everywhere. If a standard mandates it, use it with a high iteration count and a strong underlying hash (HMAC-SHA-256 or stronger). Otherwise prefer a memory-hard function.
Choosing among them
For a new application with no constraints, choose Argon2id. If you need a memory-hard function but Argon2 is unavailable, choose scrypt. If you want maximum maturity and operational simplicity, bcrypt is fine. Reach for PBKDF2 only when a compliance regime requires it.
The PHC string format
Modern libraries serialize everything you need into a single self-describing PHC string — for example $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>. That one field records the algorithm, its parameters, the salt, and the digest. This matters operationally: you can change parameters or even algorithms over time and still verify old credentials, because every stored hash carries the recipe used to produce it. Store the PHC string in a single column and let the library parse it.
Implementation correctness
The algorithm choice is necessary but not sufficient. Several implementation details routinely sink otherwise-correct systems.
Never roll your own. Use a vetted, maintained library for your language. These libraries handle salt generation, parameter encoding, and the comparison step correctly, and they have been reviewed by people who do this for a living.
Verify in constant time. Comparing the stored and computed hashes with an ordinary string equality leaks timing information that can, in principle, be exploited. Use the timing-safe comparison your library provides. Reputable password libraries do this for you inside their verify function — another reason not to assemble the pieces yourself.
Respect input length limits. bcrypt only considers the first 72 bytes of its input and silently ignores the rest, which can make long passphrases weaker than expected or, worse, make distinct long passwords interchangeable. A common mitigation is to pre-hash the password (for example, SHA-256 then Base64) before passing it to bcrypt, so the full input contributes. Argon2 and scrypt do not share this limit, but you should still cap input length to bound server-side work and avoid denial-of-service via gigantic inputs.
Rehash on login when you raise the work factor. When you increase parameters, you cannot recompute existing hashes — you do not have the plaintext. Instead, on each successful login, check whether the stored hash used outdated parameters; if so, recompute it from the password the user just supplied and overwrite the record. Over time your active users transparently migrate to the stronger setting.
Migrate legacy fast-hash databases by wrapping. If you inherit a table of unsalted MD5 or SHA-256 hashes, you cannot reverse them, and you should not wait for every user to log in before they are protected. Wrap them: feed the existing fast hash into a strong KDF (argon2id(existing_md5)), store that, and record that the credential is "wrapped." At verification time, apply the same fast hash first, then verify with the KDF. On successful login, you can rehash directly from the plaintext and drop the wrapper. This upgrades the entire database immediately rather than user by user.
Breach reality: "hashed" does not mean "safe"
Public breaches repeatedly show that hashed and safe are not synonyms. Dumps of unsalted MD5 or raw SHA-1 are cracked at enormous scale within hours because the function was fast and the hashes were unsalted. The word "hashed" in a breach disclosure tells you nothing on its own — the function and parameters are what determine whether your users' passwords are actually protected. A correctly tuned Argon2id database, by contrast, can leak and still leave strong passwords economically uncrackable.
Try it, then do it right in production
You can build intuition for all of this by hand. Our in-browser tool lets you experiment with bcrypt, Argon2, scrypt and PBKDF2 in your browser — adjust the salt and the cost factor, watch the output change, and feel how higher work factors slow things down. Everything runs client-side via Rust compiled to WebAssembly; nothing you type is uploaded.
One caveat worth stating plainly: this tool is for learning and testing, not for hashing real production passwords in a browser. Production password hashing belongs on your server, behind your application, using a vetted library and secrets that never touch the client.
Conclusion
The rules are short. Do not use a fast general-purpose hash for passwords. Salt every credential with a unique, random value stored alongside the hash. Optionally add a pepper kept outside the database for defense in depth. Use a purpose-built KDF — Argon2id by default, scrypt or bcrypt as solid alternatives, PBKDF2 for compliance — tuned to a work factor you can raise over time, and store everything in a PHC string. Lean on a vetted library, verify in constant time, mind length limits, and rehash on login as you strengthen parameters.
Want to see how the parameters behave before you wire them into your stack? Experiment with the algorithms in your browser — privately, client-side, with nothing uploaded.