DevTools Hub

Search tools

Search for a developer tool

Password Hashing Best Practices

Part of the Hashing Toolkit

Knowing that passwords need bcrypt or Argon2 instead of raw SHA-256 is the easy part — Password Hashing Explained and Bcrypt vs Argon2 already cover that ground. This is the operational checklist for what comes after you've picked an algorithm: which parameters to actually set, how to handle upgrades without breaking every existing user, and a couple of failure modes that have taken down real production systems.

Current recommended parameters

"Use Argon2" isn't a complete instruction — Argon2 with weak parameters is barely better than nothing. Per OWASP's Password Storage Cheat Sheet, in order of preference:

AlgorithmMinimum recommended parameters
Argon2idm=19456 (19 MiB), t=2, p=1
scryptN=2¹⁷ (128 MiB), r=8, p=1
bcryptcost factor ≥ 10, as high as your server can tolerate
PBKDF2-HMAC-SHA256600,000 iterations (use only where FIPS-140 compliance is mandated)

For what these Argon2id parameters actually control internally — and why RFC 9106's own recommended numbers are far higher than OWASP's baseline — see Argon2 Explained.

Treat these as a floor, not a target — OWASP's own guidance is to push each parameter as high as your hardware tolerates: "calculating a hash should take less than one second," which leaves real room above these minimums on modern server hardware. Bcrypt Cost Calculator benchmarks real bcrypt timing on your own machine and works backward from a target time to a cost factor, rather than guessing.

That said, higher isn't free: OWASP also flags the tradeoff directly — a work factor set too high "could be used by an attacker to carry out a denial of service attack," since every login now costs your server real CPU or memory too. The right number is a genuine balance, calibrated against your own server's capacity, not a value copied from a blog post (including this one).

Cap the input length before you hash it

In 2013, Django shipped a real denial-of-service vulnerability (CVE-2013-1443) because its authentication framework placed no limit on submitted password length. Its PBKDF2 implementation had a real inefficiency — it rehashed the growing password on every iteration instead of hashing it once up front — so a 1 MB "password" took roughly a full minute of CPU time to check. A handful of concurrent requests with megabyte-sized passwords was enough to peg a server.

The nuance worth knowing: this wasn't an inherent flaw in PBKDF2, or in slow hashing in general. A correct PBKDF2 implementation hashes the password once and iterates on the fixed-size result, so total time barely depends on input length at all. bcrypt sidesteps the problem differently — it only ever looks at the first 72 bytes, so anything past that is free. Argon2 hashes the input once up front too. Against a properly implemented, modern library, an absurdly long password mostly just fails to authenticate quickly.

Still, enforcing a sane maximum — 128 or 256 bytes is generous for any real password — is close to free insurance, and it's exactly what protects you if a library you're depending on turns out to have the same class of bug Django did. Reject before you hash, not after.

Store the algorithm and parameters with the hash

Every mature password hashing library encodes the algorithm identifier, version, and cost parameters directly into the stored hash string — bcrypt's $2b$12$... and Argon2's $argon2id$v=19$m=19456,t=2,p=1$... are both self-describing. That's deliberate: it means you can raise your cost parameters next year without a schema migration, without a flag day, and without breaking verification of hashes computed under the old settings — the verifier just reads whatever parameters are embedded in each individual hash. Password Hash Inspector decodes any hash you paste back into exactly this — algorithm, version, and every embedded parameter — which is a fast way to confirm what a given library actually wrote to your database.

Upgrade parameters by rehashing on login, not by touching every row

You can't recompute a stronger hash for a password you don't have — only the user's next successful login gives you the plaintext again, briefly, in memory. The standard pattern:

  • On every successful login, check whether the stored hash's embedded parameters match your current target (a lower cost factor, an old algorithm entirely). If not, recompute the hash from the plaintext you already have for this request and overwrite the stored value.
  • This means your user table will hold a genuine mix of old and new parameters for a while — expected, not a bug. Some users log in daily and upgrade immediately; others log in twice a year.
  • For inactive accounts that never trigger a rehash, OWASP's fallback is to expire old hashes outright after a long enough idle period and require a password reset — slower, but it guarantees nothing stays on a deprecated algorithm forever.

The same rehash-on-login mechanism is also how you'd migrate off a legacy fast hash entirely, which Why SHA-256 Should Not Be Used for Passwords covers in more detail if that's the situation you're in.

A pepper is optional, and it is not a salt

A salt is random, unique per password, and stored right alongside the hash — its whole job is defeating precomputed rainbow tables, and every password hashing library generates one for you automatically. A pepper is a different thing: one secret value shared across every password in the system, and — critically — not stored in the database next to the hash. It lives in a secrets vault, an HSM, or an environment variable your database credentials don't have access to.

The point of a pepper is narrow: if an attacker exfiltrates your password table alone — a SQL injection, a database backup left somewhere it shouldn't be — the hashes are useless to them without the pepper too, which they'd need to steal separately from wherever it's actually kept. OWASP describes two implementations: hashing the pepper in with the password before it reaches bcrypt/Argon2 (pre-hashing), or applying an HMAC keyed by the pepper to the already-computed hash (post-hashing) — the second is generally easier to add to an existing system without touching the underlying password hash format.

A pepper is real defense in depth, not a replacement for anything above — it protects against exactly one specific failure mode (database-only exfiltration) and does nothing for a weak hashing algorithm or an undersized cost factor. Skip it if you don't already have solid secrets-management infrastructure to keep it in; a mismanaged pepper that ends up committed next to the code it's meant to protect is worse than no pepper at all. For the full mechanics — the pre-hash vs. HMAC post-hash implementations in detail, and why rotating a pepper is genuinely harder than rotating a salt — see Understanding Salt and Pepper.

Always use the library's own verify function

Never reconstruct the hash yourself and compare it with ===. A well-built library's verify(password, storedHash) function parses the algorithm and parameters out of the stored string, recomputes with those exact settings, and compares the result using a constant-time comparison — one that takes the same amount of time regardless of where the first mismatched byte falls. A naive string comparison returns early on the first difference, which leaks a tiny but real timing signal about how many leading bytes matched. It's a narrow attack in practice, but it's also completely free to avoid: use the function the library gives you for exactly this purpose.

Hashing correctly doesn't replace rate limiting

A strong, slow hash raises the cost of an offline attack against a stolen database — it does nothing to slow down an online attacker guessing passwords directly against your login endpoint, one request at a time, where your server pays the hashing cost on their behalf either way. Rate limiting or progressive delays on failed login attempts, and account lockout or step-up verification after repeated failures, are a separate control that a correctly configured hash doesn't substitute for. The two problems — someone with your database, someone with just your login form — need different defenses, and most real systems need both.

Try it yourself

Password Hash Generator computes real bcrypt, scrypt, Argon2, and PBKDF2 hashes with configurable parameters so you can see exactly what each setting above produces, Password Hash Inspector decodes an existing hash back into its algorithm and parameters, and Bcrypt Cost Calculator benchmarks real hashing time on your own hardware to help pick a cost factor that hits a target latency instead of guessing one. All three run entirely in your browser.

Related tools