How password hashing works: bcrypt, scrypt, Argon2, and the salt
What a verifier stores instead of your password, why bcrypt, scrypt and Argon2 are slow on purpose, and why the salt is not a secret.
If you run a login form, the thing you store is not the password. It is the output of a deliberately slow function of the password and a random value, and the whole design exists for one day you hope never comes: the day someone copies your user table. This article explains what that function does, why the three names you keep hearing (bcrypt, scrypt, Argon2) are slow on purpose, why the salt sitting next to the hash in plain view is fine, and which parameters the published guidance actually names. Nothing here helps anyone attack a table they stole; all of it helps you make the table not worth stealing.
What a verifier needs to store
A verifier has one job at login: decide whether the string the user just typed is the same string they registered with. It does not need the original string to do that. If you store H(password) for some one-way function H, you can recompute H(typed) and compare. A plain cryptographic hash like SHA-256 satisfies "one-way" but fails the real requirement, because it is fast. Fast is exactly the property an attacker with a stolen table wants: they guess candidates, hash each one, and compare against every row.
So the function has to be slow, and slow in a way that does not get cheaper on specialised hardware. NIST SP 800-63B (the 2025 revision, Section 3.1.1.2) puts it as a requirement: memorized secrets "SHALL be salted and hashed using a suitable password hashing scheme", the salt "SHALL be at least 32 bits in length and chosen to minimize salt value collisions", and the cost factor "SHOULD be as high as practical without negatively impacting verifier performance". Both the salt and the hash are stored for each password.
Why the salt is not a secret
The salt does two things, and neither requires it to be hidden.
First, it makes identical passwords produce different stored values. Without a salt, every user with the same password has the same hash, and one guess that matches one row matches all of them. With a per-user random salt, the attacker has to run the slow function once per row per guess. Second, it defeats precomputation. A table of hashes computed in advance for common passwords is useless if every stored hash also depends on a value the attacker did not know when they built the table.
Both effects come from the salt being unique and unpredictable, not from it being confidential. The password-hashing functions below take the salt as an input and emit it as part of their output string, in the clear. That is by design. If your threat model needs a secret, that is a separate ingredient: OWASP calls it a pepper, a value "shared between stored passwords" that "should not be stored along with the generated hash". NIST describes the same idea as an additional keyed iteration with a key that "SHOULD be stored and used within a hardware-protected area, such as a hardware security module". A pepper helps when the database leaks but the application server does not. It does not replace the salt.
bcrypt: old, still fine, with a 72-byte catch
bcrypt (1999) runs a modified Blowfish key schedule for 2^cost rounds. The cost is a small integer; each increment doubles the work. OWASP's current guidance is that "the work factor should be as large as verification server performance will allow, with a minimum of 10", and it now lists bcrypt under legacy systems rather than as the first choice.
The catch every implementer should know is the input limit: "bcrypt has a maximum input length of 72 bytes". Bytes, not characters. Longer inputs are silently truncated in most implementations, which means two different long passwords can verify as the same one. OWASP's advice is to enforce a maximum password length of 72 bytes when bcrypt is in use, rather than pre-hashing, which introduces its own pitfalls.
scrypt: the first memory-hard design
bcrypt's work is compute-bound, and compute is what GPUs and ASICs are good at. scrypt (2009) added a second dimension: it fills a large block of memory with pseudorandom data and then reads it back in a data-dependent order. An attacker who wants to run many guesses in parallel now needs many copies of that memory, which is where custom hardware loses its edge. OWASP's fallback parameters when Argon2id is unavailable are N=2^17 (128 MiB), r=8, p=1, "or other configurations providing equivalent security through different memory/parallelism tradeoffs".
Argon2id: the current recommendation
Argon2 won the Password Hashing Competition in 2015 and is specified in RFC 9106 (September 2021). The RFC describes it as "a memory-hard function" that "aims at the highest memory-filling rate and effective use of multiple computing units, while still providing defense against trade-off attacks". The id variant mixes data-independent and data-dependent memory access, which is why both OWASP and the RFC name it as the default.
RFC 9106 Section 4 gives two recommended settings: "Argon2id with t=1 iteration, p=4 lanes, m=2^(21) (2 GiB of RAM), 128-bit salt, and 256-bit tag size", and the lighter "t=3 iterations, p=4 lanes, m=2^(16) (64 MiB of RAM)". OWASP publishes a ladder of equivalent-strength settings for servers that cannot spare 2 GiB per login, from "m=47104 (46 MiB), t=1, p=1" down to "m=7168 (7 MiB), t=5, p=1", with a stated floor of "m=19456 (19 MiB), t=2, p=1".
| Function | Type of cost | Reference setting | Where the guidance puts it |
|---|---|---|---|
| Argon2id | memory and time | m=19 MiB, t=2, p=1 (OWASP minimum); m=64 MiB, t=3, p=4 (RFC 9106) | first choice |
| scrypt | memory and time | N=2^17, r=8, p=1 | when Argon2id is unavailable |
| bcrypt | time | cost >= 10, input capped at 72 bytes | legacy systems |
| PBKDF2-HMAC-SHA256 | time | 600,000 iterations | FIPS-140 environments |
The PBKDF2 row exists because some compliance regimes require a FIPS-approved primitive; OWASP's iteration counts are 600,000 for SHA-256, 220,000 for SHA-512, and 1,400,000 for SHA-1 (legacy only).
A worked verification, with the salt in view
The standard library does not ship Argon2, so this listing uses hashlib.scrypt, which it does ship. The shape is the same for any of the three: generate a salt, run the slow function, store a string that carries the parameters, the salt, and the tag; at login, parse the string, rerun with the same parameters and salt, and compare in constant time.
import base64, hashlib, hmac, os
N, R, P = 2**17, 8, 1 # OWASP's scrypt fallback parameters (128 MiB)
def hash_password(password: str) -> str:
salt = os.urandom(16) # 128 bits; NIST's floor is 32 bits, more is free
tag = hashlib.scrypt(password.encode(), salt=salt, n=N, r=R, p=P, maxmem=2**28, dklen=32)
return "scrypt$%d$%d$%d$%s$%s" % (N, R, P, base64.b64encode(salt).decode(), base64.b64encode(tag).decode())
def verify(password: str, stored: str) -> bool:
_, n, r, p, salt_b64, tag_b64 = stored.split("$")
salt, tag = base64.b64decode(salt_b64), base64.b64decode(tag_b64)
candidate = hashlib.scrypt(password.encode(), salt=salt, n=int(n), r=int(r), p=int(p), maxmem=2**28, dklen=32)
return hmac.compare_digest(candidate, tag)
Look at what the stored string contains: the algorithm name, the three parameters, the salt, and the tag, all readable. An attacker who copies it learns exactly how much work each guess costs them. That is the point. Every real password-hashing library emits the same kind of self-describing string, because carrying the parameters is what lets you raise them later.
Raising the cost over time
Hardware gets faster, so a cost that was right in 2019 is light now. Because the parameters travel with the hash, you can upgrade lazily: at each successful login you already have the plaintext for a moment, so if the stored parameters are below your current policy, rehash with the new ones and overwrite. Users who never log in keep the old hash, which is a reason to have an expiry for dormant accounts rather than a reason to avoid upgrading.
A legacy table of unsalted fast hashes can be wrapped rather than waited out: compute Argon2id(old_hash) for every row now, mark the rows as wrapped, and unwrap to a direct Argon2id hash at next login. OWASP describes this pattern for upgrading legacy hashes. It closes the fast-hash exposure immediately instead of at the user's convenience.
What to do
- Pick Argon2id, at or above OWASP's minimum of 19 MiB and t=2, and measure the login latency on your real hardware before settling. If the library is unavailable, scrypt at N=2^17; bcrypt only where you already have it, with a 72-byte input cap.
- Generate a 16-byte random salt per password from the operating system's CSPRNG, store it beside the hash, and do not treat it as a secret.
- Add a pepper only if it lives somewhere the database does not, and rotate it with a plan for rehashing.
- Compare tags with a constant-time function, as the listing does with
hmac.compare_digest. - Store the parameters with the hash, set a policy version, and rehash on login when the stored parameters fall below it.
- Remember what hashing does not do: it does not stop credential stuffing with passwords stolen from somewhere else, and it does not stop a weak password from being guessed in a handful of tries. Those need a blocklist and rate limiting, which NIST covers in the same section, and which the entropy article on this site takes up next.
The limitation to state plainly: slow hashing buys time, measured in attacker cost per guess. It does not make a password that is on every guess list survive. For those, the only fix is not to allow them in the first place.