Security / security / authentication / credentials
The Slowness Is the Feature
That salting a fast cryptographic hash such as SHA-256 makes it suitable for passwords, because the salt is what stops the attack. The salt stops precomputation and stops one computation covering many accounts; it does not make a single guess cost one cycle more. What makes guessing uneconomic is the work factor — the deliberate cost of each evaluation — and a general-purpose hash is fast by design, which is precisely the property you do not want here.
SHA-256 is a hash function. bcrypt is a key derivation function — a KDF, a function deliberately built to be slow and to let you choose how slow. Both turn a password into a fixed-length value you can store. Only one of them was designed on the assumption that somebody will one day run it a trillion times, and that is the only assumption that matters here.
The belief worth breaking is that the salt is what protects the table. It is not. A salt is a per-account random value mixed into the hash, and its job is to stop one computation from covering many accounts: without it, one guess is tested against every row at once and precomputed lookup tables apply. That job is real and the salt does it completely. What the salt does not do is make a single guess cost one instruction more. The thing that makes a guess expensive is the work factor, and a general-purpose hash has none.
The panel below is the decision you are actually making. Pick an algorithm and a work factor; it computes what one login costs your server, what one guess costs somebody with the stolen table, and how much of that table comes back. Start on the sha256(salt + password) preset and read the recovered figure. Then switch the salt to none. The number barely moves.
Real, from the specifications: OWASP's Password Storage Cheat Sheet gives Argon2id m=19456 (19 MiB) t=2 p=1, scrypt N=217 r=8 p=1, bcrypt work factor 10 or more with a 72-byte password limit, and PBKDF2-HMAC-SHA256 at 600,000 iterations. RFC 9106 §4 names Argon2id t=1 p=4 m=221 (2 GiB) as its FIRST RECOMMENDED option and t=3 p=4 m=216 (64 MiB) as its second. NIST SP 800-63B §3.1.1.2 requires a salt of at least 32 bits. Modelled, not measured: every throughput figure below. One CPU core is modelled at 2 million bare SHA-256 per second and 2,000 MiB/s of effective memory bandwidth; one rented accelerator at 25 billion bare SHA-256 per second and 1,000,000 MiB/s. The password distribution is modelled too — the top thousand guesses cover 5% of accounts, the top million 20%, the top billion 40%. These are illustrative orders of magnitude, calibrated so that bcrypt cost 10 lands near 100 ms on one core. Nothing here is a benchmark.
recovered · survived. The work factor does not save the worst passwords and cannot endanger the best ones. Everything it buys is in the middle bands, and that is where most of your users are.
With five million accounts, eight rented accelerators and a month, bare SHA-256 gives back about 60% of the table, and adding a 128-bit per-user salt to that same SHA-256 gives back about 50%. Ten points, for the control that most engineers name when asked what protects a password database. Now switch the algorithm to bcrypt at cost 10 and the figure drops to around 12%; at Argon2id with 2 GiB it is about 4.5%. The salt changed who the attacker has to attack. The work factor changed whether they can afford to.
What the salt is for, stated exactly
A salt is a random value, unique per stored password, mixed into the hash input and stored in plain sight next to the result. It is not a secret and the specifications do not treat it as one — NIST SP 800-63B §3.1.1.2 says "both the salt value and the resulting hash SHALL be stored for each password", in the same row, together.
It buys two things and only two. First, precomputation dies: a table of
digests built once and reused against every breach is useless when every row
has its own salt, because the attacker would have needed a separate table
per salt. Second, work stops being shared: without a salt, one evaluation of
the candidate Summer2024! is compared against all five million
rows in one pass, so the cost of covering the whole table equals the cost of
covering one account. With per-user salts, covering the table costs
candidates × accounts evaluations instead of candidates.
Set the salt selector to one salt for the whole application and compare it with per-user, 128 bits at bcrypt cost 12. Recovery goes from roughly 48% to roughly 9%. That is the salt doing its actual job, and it is a large effect — five million times more evaluations required. Then go back to SHA-256 with the same per-user salt and watch it evaporate, because five million times a number that small is still a number the attacker can afford.
Now the part that is usually missed. Select per-user, 16 bits with five million accounts and read the log. Sixteen bits is 65,536 possible values; five million rows drawn from 65,536 buckets means almost every salt is shared with dozens of other rows, and each of those repeats is an evaluation the attacker skips. The multiplier collapses from five million to about sixty-five thousand. This is precisely why NIST specifies a floor of 32 bits and adds "chosen to minimize salt value collisions among stored hashes". Move the account slider to fifty million with 32 bits selected and the collision loss is still about one percent; the floor holds, but it is a floor and not a target.
None of this helps against a targeted attack. If somebody wants one specific account, there is one salt, and the whole guess budget goes at it. The per-account guess figure in the readouts is the shared-attack number; for a single target, multiply it by the distinct-salt count in the log. That is the number that has to be uneconomic, and only the work factor moves it.
Pepper is a different mechanism with a different failure
A pepper is a single secret key, shared across all stored passwords, mixed into the derivation and deliberately not stored with them. NIST describes it as "an additional iteration of a keyed hashing or encryption operation using a secret key known only to the verifier", and requires that "the secret key value SHALL be stored separately from the hashed passwords", preferably inside a hardware security module or a trusted execution environment.
Tick the pepper toggle with SHA-256 selected. Recovered accounts go to zero — at the worst algorithm choice on the list. This is not the work factor doing anything. A candidate can only be checked by computing the stored function, and one input to that function is a 256-bit key that was not in the dump. There is nothing to guess against.
Now tick they also got the application server. Everything reverts. That is the whole shape of a pepper: an enormous benefit conditional on one binary fact about the breach, and no benefit at all if that fact goes the other way. Database-only compromise — a SQL injection, a leaked backup, a misconfigured replica — is common enough that the pepper is worth having. Full application compromise is common enough that you must not price the pepper into your work factor.
There is an operational cost people discover late. A pepper cannot be rotated without the plaintext passwords, which you do not have. Rotating it means either re-peppering lazily at each user's next login while keeping a key-id per row, or forcing a password reset for everyone still on the old key. Store a key identifier alongside the hash from day one or rotation is not available to you at all.
The post-hashing form avoids most of this:
hmac(pepper_key, argon2id(password, salt)). The expensive
derivation is unchanged, the pepper is a cheap outer layer, and you can
re-key by unwrapping and rewrapping without ever seeing a password. It also
keeps the pepper out of the KDF's input length limits, which matters for
the same reason constant-time comparison
matters: fewer moving parts in the hot path.
The work factor is chosen by your login latency, not by a blog post
OWASP is explicit that there is no universal number: "there is no golden rule for the ideal work factor — it will depend on the performance of the server and the number of users on the application", with a general ceiling that "calculating a hash should take less than one second". NIST says the same thing normatively: the cost factor "SHOULD be as high as practical without negatively impacting verifier performance" and "SHOULD be increased over time to account for increases in computing performance."
"As high as practical" has a number attached and the panel computes it. Put the algorithm on bcrypt and walk the cost from 10 to 14. Verify latency goes 100 ms → 200 → 400 → 800 → 1,600, and the logins per second your eight cores can serve goes 80 → 40 → 20 → 10 → 5. Set peak logins to 200 and the logins/s readout turns amber at cost 10 already. Your work factor was decided by your traffic before you read any recommendation.
This is not merely a capacity planning nuisance. A login endpoint that consumes 400 ms of CPU per request is an endpoint where a few dozen concurrent wrong passwords saturate the tier, and nothing about the request is privileged — anyone can send it. OWASP names it: "if the work factor is too high, the performance of the application may be degraded, which could be used by an attacker to carry out a denial of service attack by exhausting the server's CPU". The mitigations are boring and necessary: rate limit per account and per source before you hash, keep hashing off the request thread where you can, and size the tier for the wrong-password case rather than the happy path.
Memory-hard functions add a second constraint that the latency number hides. Select Argon2id and take the memory slider to 2048 MiB — the memory of RFC 9106's FIRST RECOMMENDED option, which is t=1, p=4, m=221. The panel holds t=2, p=1 throughout, so it charges about twice the RFC's time for the same memory; halve the latency to read it as the RFC intends. Even halved: roughly two seconds on one core, four logins per second across eight cores, and a RAM held at peak readout in the gigabytes. The reason almost nobody runs the RFC's first choice is not that they disagree with the RFC; it is that a login tier holding 16 GiB of Argon2 buffers is a different piece of infrastructure. The same RFC's second recommended option, 64 MiB with t=3, exists for this reason, and OWASP's 19 MiB with t=2 is lower again.
Compare the two honestly with the panel. Argon2id at 19 MiB and bcrypt at cost 10 land within a couple of percentage points of each other on accounts recovered, because 19 MiB with two passes is only 76 MiB of memory traffic per hash, and an accelerator moves roughly a million MiB every second. Argon2id's real advantage is not that it beats bcrypt at the minimum setting — it is that it has a memory dial at all. bcrypt's footprint is 4 KiB and always will be, so raising its cost buys you time and never buys you memory. Every doubling of Argon2's memory halves the number of lanes an accelerator can hold and doubles the time each one takes, which is why the recovery figure falls so much faster there than it does along the bcrypt cost axis.
Raising it later is the part nobody builds
A work factor chosen in 2019 and never touched is a work factor that has quietly halved in value several times. NIST's "SHOULD be increased over time" is not aspirational — it is the only reason the parameter is stored in the hash string at all.
Every modern format carries its own parameters, which is what makes
migration possible. A bcrypt string is
$2b$12$<22-char salt><31-char digest> — the
algorithm identifier, the cost, and the salt, all in the value you stored.
Argon2 and scrypt use the PHC string format,
$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>. NIST
asks for this explicitly: "a reference to the password hashing scheme used,
including the cost factor, SHOULD be stored for each password to allow
migration to new algorithms and work factors."
The upgrade itself has exactly one safe trigger, which is a successful login, because that is the only moment you hold the plaintext:
- Verify against the parameters parsed out of the stored string, whatever they are.
- If verification succeeded and the parsed parameters are below
your current policy, re-derive with the current policy and write the new
string. Most libraries expose this as one call —
password_needs_rehashin PHP,CheckPasswordHashplus a cost comparison in Go's bcrypt,PasswordHasher.needs_updatein passlib. - Count the rows still on the old parameters. This is a metric, not a one-off migration: it decays as users return, and the tail never empties.
For that tail, and for an algorithm change rather than a work-factor bump,
the option OWASP describes is to wrap rather than wait: store
argon2id(sha256(old_hash)) and record that the row is wrapped.
You never needed the password. The cost is that the inner value is a fast
hash of a password, so if it leaked previously the wrapper is protecting
something already public — which is why OWASP's advice is to replace the
wrapped rows with direct hashes at each user's next login anyway.
Do not build the other version of this, where an outer fast hash feeds a
slow one. bcrypt(sha512(password)) is a documented hazard:
bcrypt reads a null-terminated string and stops at the first zero byte, so
any digest whose first byte is zero produces the hash of the empty string,
and roughly one password in 256 lands there. The 72-byte input limit is
real too — OWASP's guidance is to enforce a 72-byte maximum rather than let
the library silently truncate, because a user whose passphrase is 80 bytes
is protected by 72 of them and does not know it.
The comparison at the end
All of the above concerns the derivation. The last step is comparing the derived value with the stored one, and the ordinary way to compare two strings returns as soon as it finds a difference. That makes the time it takes a function of how many leading bytes matched, which is information about the stored value leaking out through the clock.
The instrument below measures a comparison routine — no network, no target, just the routine's own timing. Each bar is the mean measured duration for inputs sharing that many leading bytes. A single measurement tells you nothing because the jitter dwarfs the signal. Averaging shrinks the error bar as the square root of the sample count, and at some number of samples the bars separate. Move samples averaged and find where that happens.
Each bar is the mean of 1 measurements, drawn from the sampling distribution the jitter implies — which is what averaging actually does, rather than a smooth curve. the bars are separated by more than their error, so the prefix length is readable · it is not. Modelled: 2.4 ns per byte compared. The jitter range spans a local function call at the low end and an internet round trip at the high end.
The honest reading is narrower than the usual advice implies, and it is worth having straight because it is the thing eight answers on Stack Exchange disagree about. In a password check the value being compared is a hash, not the password, and it is a hash the attacker cannot steer: to learn the stored digest one byte at a time you would have to produce inputs whose derived value has a chosen prefix, which is the preimage problem the KDF is built on. So the leak here is genuinely weak, and the same leak in a bearer-token or HMAC comparison — where the attacker can supply the compared bytes directly — is genuinely serious.
None of which is a reason to branch on secret data. The constant-time
version costs nothing, exists in every standard library, and removes a whole
class of reasoning from your review. Use hash_equals in PHP,
hmac.compare_digest in Python,
crypto.timingSafeEqual in Node,
subtle.ConstantTimeCompare in Go. The one caveat is that these
compare in constant time for equal-length inputs and most of them
reveal a length mismatch immediately; for digests that is fine, because the
length is fixed and public.
Checking it yourself
Measure, do not assume. In a shell on the machine that will actually run your login endpoint:
- Python:
python -m timeit -n 20 -s "import bcrypt; p=b'x'*20; s=bcrypt.gensalt(12)" "bcrypt.hashpw(p,s)". Take the per-loop figure, not the total. - Argon2:
echo -n password | argon2 somesalt -id -m 15 -t 2 -p 1prints the elapsed time per phase;-m 15is 215 KiB, so 32 MiB. - Then divide: your target is a latency you can afford at peak, so cores ÷ target seconds must exceed your ninety-fifth-percentile logins per second with margin for the wrong-password flood.
In the database, one query tells you whether your migration is real. If the hash column is a PHC or bcrypt string, the parameters are in it:
SELECT split_part(password_hash, '$', 2) AS scheme,
split_part(password_hash, '$', 3) AS params,
count(*)
FROM users GROUP BY 1, 2 ORDER BY 3 DESC;
Anything that is not your current policy is a row an attacker gets cheaper
than you think. If the answer comes back as one row with a 32-character hex
value and no $ at all, you are looking at an unsalted MD5 or
SHA-1 column and the wrapping migration above is the next thing to build.
Two more checks that take a minute each. Confirm your framework is not
truncating: register a 100-character passphrase, then try to log in with its
first 72 characters. If that succeeds you are on bcrypt without an explicit
length limit. And grep the authentication path for == against
anything derived from a stored secret — the comparison is almost always fine
inside the library and almost always hand-rolled in the "remember me" cookie
check next to it, which is the case where it matters. That is the same class
of mistake as
verifying a token's signature and stopping there: the
expensive step was done correctly and the cheap step next to it was not.
Finally, know what this whole mechanism does not address. A work factor protects passwords the attacker has to guess. It does nothing whatsoever when they already have the plaintext from somewhere else and are simply logging in — see why a perfect hash does not stop a reused password — and it stops mattering the moment the session exists, at which point the question becomes what you can actually revoke.
You store sha256(per_user_salt || password) for 5,000,000
accounts. The table leaks. Compared with storing the same passwords under
bcrypt cost 12 with the same salts, roughly how many more accounts does
the attacker recover in a month on eight rented accelerators?
Next: the attack that no work factor touches, because the attacker already knows the password — credential stuffing and what actually stops it — and the general discipline that constant-time comparison is one instance of, not branching on secrets.