๐งฎ Passwords, Hashing and Credential Attacks
Passwords, Hashing and Credential Attacks
Password security is usually taught backwards, as a list of composition rules for users. The useful way round is to start from how credentials are actually stolen, because that determines which defences are worth anything.
How credentials are really attacked
- Credential stuffing โ the dominant attack by volume. Nobody cracks your users' passwords; they replay username/password pairs from other sites' breaches against your login form, because a large fraction of people reuse. It needs no cryptography at all, only a proxy pool. Defences: unique passwords (so a manager), MFA, checking new and changed passwords against breach corpora, and rate limiting that is keyed on the account as well as the IP.
- Password spraying โ the inverse. One or two very common passwords tried against thousands of accounts, staying under any per-account lockout threshold. Defences: breach/common-password blocklists at registration, monitoring for a high rate of failures spread across many accounts, and MFA.
- Offline cracking โ the attacker already has your hash dump and is limited only by GPU time. This is the only attack your hashing choice affects, and it affects it enormously (see below).
- Phishing and adversary-in-the-middle โ a reverse-proxy phishing kit sits between the user and the real site, relays the login and the one-time code in real time, and steals the resulting session cookie. TOTP, push approvals and SMS codes all fall to this, because none of them are bound to the site the user is actually talking to. Only origin-bound credentials (WebAuthn/passkeys) genuinely resist it.
Why the hash function choice is everything
General-purpose hashes are designed to be fast, and speed is the attacker's entire budget. Published GPU benchmarks put a single high-end consumer card on the order of 1011 MD5 or NTLM guesses per second, and roughly 1010 for SHA-256 โ while bcrypt at cost 12 sits around 103 guesses per second on the same hardware. That is a factor of roughly a hundred million, bought with one configuration value. (Exact figures move with hardware; the ratio is the point.)
| Algorithm | Use it? | Concrete parameters |
|---|---|---|
| Argon2id (RFC 9106) | First choice | OWASP's minimum is m=19 MiB, t=2, p=1. RFC 9106's first recommendation, where memory allows, is much heavier: t=1, m=2 GiB, p=4, with t=3, m=64 MiB, p=4 as the second option. Tune upward until one hash costs roughly 0.25โ1 s of server time at your peak login concurrency. |
| bcrypt | Fine, and everywhere | Cost factor 10 at an absolute minimum, 12 or higher preferred. Cost is a base-2 exponent: 12 is four times the work of 10. Note the 72-byte input limit below. |
| scrypt (RFC 7914) | Good if Argon2 is unavailable | N=217, r=8, p=1 as a floor. N is memory and CPU; r and p tune block size and parallelism. |
| PBKDF2 (RFC 8018) | Only when FIPS requires it | OWASP's current iteration guidance: 600,000 with HMAC-SHA-256, 1,300,000 with HMAC-SHA-1, 210,000 with HMAC-SHA-512. It has no memory cost, so GPUs and ASICs keep their advantage. |
| SHA-256 / SHA-1 / MD5 | Never, for passwords | Not even "but it's salted" โ see below. |
bcrypt(base64(SHA-256(password))). Base64 matters, because feeding raw binary in can reintroduce the NUL problem. Read the shucking warning further down before adopting that pattern.
Salts are per-user random values, at least 16 bytes from a CSPRNG, stored in plaintext next to the hash. They are not secret. Their job is to stop precomputation โ one rainbow table cannot cover every user, and two users with the same password no longer have the same hash. Peppers are a single secret value that is deliberately not in the database, ideally in an HSM or KMS: apply HMAC-SHA-256(pepper_key, password) before the slow hash, so a stolen database with no application secret is uncrackable. Peppers complicate rotation, so version them.
Storing the parameters with the hash
Never store a bare digest. Every modern algorithm serialises its parameters into the hash string, which is what lets you raise the cost later without touching existing rows:
$argon2id$v=19$m=19456,t=2,p=1$<base64 salt>$<base64 derived key>
| | | | |
| | | | +-- the derived key itself
| | | +-- per-user salt
| | +-- memory in KiB, iterations, parallelism
| +-- Argon2 version 19 (0x13)
+-- algorithm identifier
$2b$12$<22-char salt><31-char hash> # bcrypt: $2b$ variant, cost 12
On every successful login, check whether the stored parameters are below current policy and, if so, re-hash the plaintext you already have in memory and update the row. That is how a hash store upgrades itself with no user disruption.
Migrating a legacy store without a mass reset
If you inherit unsalted md5(password), you cannot recover plaintexts โ so wrap them. Store argon2id(md5_hash) along with a scheme marker, and at login apply the legacy transform first, then the new one. Every existing row is upgraded immediately and offline cracking now faces Argon2. On the next successful login you can quietly replace the wrapped entry with a direct argon2id(password) and clear the marker.
Two more implementation details that matter: compare derived keys with a constant-time function, and run a dummy hash of the same cost when the username does not exist โ otherwise your login endpoint answers "no such user" in 2 ms and "wrong password" in 300 ms, which is a free user-enumeration oracle.
What actually makes a password strong
Entropy in bits is length ร log2(alphabet size) โ but only when the password was generated uniformly at random. Applied to a human-chosen password the formula is fiction: P@ssw0rd!23 scores 72 bits on the naive calculation and falls to the first rule-based wordlist attack, because cracking tools apply exactly the substitutions humans think are clever. Length and unpredictability are what matter; character-class rules mostly produce Password1!.
| Secret (randomly generated) | Keyspace | Entropy | Full search at 1011/s (fast hash) | At 103/s (bcrypt cost 12) |
|---|---|---|---|---|
| 8 chars, aโz0โ9 | 368 โ 2.8ร1012 | 41 bits | ~30 seconds | ~90 years |
| 12 chars, aโzAโZ0โ9 | 6212 โ 3.2ร1021 | 71 bits | ~1,000 years | ~1011 years |
| 6-word EFF diceware passphrase | 77766 โ 2.2ร1023 | 77 bits | ~70,000 years | ~1013 years |
| 16 chars, all 95 printable ASCII | 9516 โ 4.4ร1031 | 105 bits | ~1013 years | ~1021 years |
Halve those times for the expected (average) case. Two lessons fall out. First, an 8-character password is not a password against an offline attack, whatever symbols it contains. Second, the difference between the first row and the second is 30 seconds versus a millennium โ and it cost four extra characters.
Diceware generates passphrases by choosing words uniformly at random from a published list; the EFF long list has 7776 entries, so each word contributes log2(7776) โ 12.9 bits. Six words is about 77 bits and is genuinely typeable and memorable. The critical requirement is that the words are chosen by dice or a CSPRNG โ a phrase you thought of is not diceware and carries a small fraction of that entropy. Estimate a password's strength โ ยท generate one โ
What NIST actually says
NIST SP 800-63B (Digital Identity Guidelines: Authentication and Lifecycle Management) is the standard people cite for the old advice and almost never for the current advice. Since the 2017 revision it says, for verifiers:
- Require a minimum of 8 characters and support at least 64; the Revision 4 update recommends a higher minimum around 15 characters.
- Accept all printing ASCII, the space character, and Unicode.
- Do not impose composition rules โ no "must contain an uppercase letter and a symbol".
- Do not require periodic rotation. Force a change only on evidence of compromise. Scheduled expiry drives users to predictable increments.
- Compare prospective passwords against a blocklist of commonly used, expected and compromised values, and tell the user why one was rejected.
- Allow paste (password managers depend on it) and offer a "show password" toggle.
- No password hints, no knowledge-based "security questions".
- Store passwords with a salt of at least 32 bits and a suitable one-way key derivation function; a keyed hash whose key is stored separately from the database is additionally recommended.
- Throttle failed attempts โ the guidance caps consecutive failures at 100 per account.
Checking against breach corpora is the highest-value item on that list, and it does not require handing the password to anyone. The k-anonymity range API takes only the first five hex characters of the password's SHA-1 hash, returns every hash suffix that shares that prefix โ hundreds of them โ and the match is made locally. This site's breach tool works exactly that way: the password and its full hash never leave your browser, and the five-character prefix reveals nothing useful. Check a password against breach corpora โ
Moving past the password
TOTP (RFC 6238)
Time-based one-time passwords are HOTP (RFC 4226) with the counter replaced by the clock. Server and authenticator share a base32 secret; both compute:
T = floor((unix_time - T0) / X) # T0 = 0, X = 30 seconds
H = HMAC-SHA-1(K, T as 8-byte big-endian) # 20 bytes
o = H[19] & 0x0f # dynamic truncation offset
S = (H[o] & 0x7f) << 24 | (H[o+1] & 0xff) << 16
| (H[o+2] & 0xff) << 8 | (H[o+3] & 0xff)
code = S mod 10^6 # zero-padded to 6 digits
otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA1&digits=6&period=30
Servers normally accept the adjacent time steps to tolerate clock drift, and must mark a used code as spent so it cannot be replayed within its window. TOTP's limits are structural: the secret is symmetric, so a server-side breach exposes every user's seed, and the code is not bound to a site, so it can be phished and relayed in real time. It is still a very large improvement over a password alone. Generate and verify TOTP codes โ
SMS
SMS one-time codes are phishable like TOTP and exposed to SIM swapping, carrier account takeover, and interception in the signalling network. NIST treats SMS as a restricted authenticator. It is better than nothing and worse than everything else; if you offer it, do not make it the account-recovery backdoor that bypasses stronger factors.
WebAuthn and passkeys
WebAuthn (a W3C standard, with FIDO2/CTAP2 underneath) replaces the shared secret with a per-site key pair. Registration stores a public key on the server; authentication has the authenticator sign a challenge. Two properties make it categorically different:
- Origin binding. The credential is scoped to a Relying Party ID derived from the real domain, and the browser includes the origin in the signed data. A look-alike domain cannot obtain a valid assertion โ so a real-time proxy phishing kit, which defeats every code-based factor, simply fails. This is the whole point.
- No shared secret to steal. The server holds only public keys. A dump of the credential table is not a credential dump, so there is nothing to crack and nothing to stuff into other sites.
Passkeys are WebAuthn credentials that a platform may sync across a user's devices, trading a little hardware binding for the recovery story that kept security keys niche. Keep an eye on the recovery path โ an account that falls back to an emailed link is only as strong as the mailbox โ and remember that phishing resistance is a property of the credential, not of the word "MFA".
Try it: password strength analyzer โ ยท breach check (k-anonymity) โ ยท password generator โ ยท TOTP tool โ ยท hash generator โ
- Credential stuffing beats cracking by orders of magnitude in the real world; unique passwords, MFA and breach-list checks are the counters.
- Argon2id first, bcrypt (cost โฅ 12) as the pragmatic default, scrypt as an alternative, PBKDF2 only for FIPS. Never a bare fast hash.
- Salts stop precomputation; only slowness stops throughput. "Salted SHA-256" is still broken for passwords.
- bcrypt silently truncates input past 72 bytes โ pre-hash long inputs carefully, and be aware of password shucking when the inner hash is unsalted.
- Store algorithm parameters with the hash, and re-hash on login when they fall below policy.
- Length and randomness beat character-class rules; 8 characters is not a password against an offline attack.
- SP 800-63B: check against breach lists, allow long passphrases and paste, drop composition rules and scheduled rotation.
- TOTP and SMS are phishable in real time. WebAuthn/passkeys are origin-bound, which is what actually stops proxy phishing.