Skip to content

Storing credentials, slow password hashing, MFA and passkeys

A salt makes the attacker guess one account at a time; only a slow, memory-hard hash makes each guess expensive.

Predict first

Before you read: predict

A ticket reseller's old user table leaked last year: 1,000 accounts stored as plain SHA-256(password). A list of 100 common passwords cracked 122 of them. Now the same 1,000 accounts leak from the new system, which stores SHA-256(salt + password) with a random 16-byte salt per account, kept in the same row. The attacker runs the same list.

How many accounts does the new table give up?

Pick one

The 30-second version

Store a slow, salted verifier, never the password. A unique salt per account forces guesses to be made account by account and hides shared passwords, but makes no single guess harder. Only the work factor (iterations, memory) does, so plain SHA-256 is wrong. A second factor adds possession: a TOTP code from a shared secret, or a passkey's origin-bound signature.

A salt makes the attacker guess one account at a time; only a slow, memory-hard hash makes each guess expensive.

How it works

Step 1 of 17

Store a verifier: at sign-in, recompute from the typed password and compare. Never store the password, or encrypt it reversibly.
In the kitchen
  • each lock opens for one word, written down nowhere → a verifier, not the password (step 1)

Analogy: A burglar's sack of locked diaries

A burglar steals a sack of diaries, each with a small combination lock, and takes them home to try words.

With the diaries In the system
each lock opens for one word, written down nowhere a verifier, not the password (step 1)
at home he tries words at leisure; no one watches offline guessing (step 2)
every lock of one model opens alike: one try on a spare shows every diary "sunshine" opens no salt (step 4)
each lock carries its own printed twist number, so each word is tried diary by diary a salt: visible, per account (steps 3–4)
still, every diary locked with "sunshine" opens in the end a salt makes no guess harder (step 5)
a stiff dial needing fifty full turns, and a big workbench, for every try work factor, memory (steps 6, 8)
one part of every lock stays in the maker's workshop a pepper (step 12)
a second clasp whose code changes every half minute, from a card the owner and the maker share TOTP (steps 14–15)

In real terms: judge a stored format by what one guess costs the attacker, per account, with the table in hand.

Breaks down when: you picture picking the lock. A hash is never opened, only guessed. And no lock holds a passkey's public-key half.

Contrasting case: an attacker without the table, guessing at the live login. There the endpoint's limits decide (step 13), not the hash.

Examples, run for real

4.1 ExamHall: proctor passwords

EXH-1420: a leaked staging backup held proctor passwords as unsalted SHA-256. MR !1620 moves them to Argon2id and upgrades each row at its next sign-in.

4.1 proctor rows before MR !1620
  formats: p-1 sha256-hex (unsalted) · p-2 sha256-hex (unsalted) · p-3 sha256-hex (unsalted) · p-4 sha256-hex (unsalted)
  distinct stored values: 3 of 4 (p-1 and p-2 share a password)
  logins: p-1 ok, rehashed · p-2 ok, rehashed · p-3 ok, rehashed · p-1 wrong password rejected
  formats: p-1 $argon2id$v=19$m=19456,t=2,p=1$<salt>$<tag> · p-2 $argon2id$v=19$m=19456,t=2,p=1$<salt>$<tag> · p-3 $argon2id$v=19$m=19456,t=2,p=1$<salt>$<tag> · p-4 sha256-hex (unsalted)
  distinct stored values: 4 of 4 · p-4 never logged in, still unsalted SHA-256

The shared password showed as equal hashes until salting (step 4). p-4 waits for a sign-in or a forced reset (step 10).

4.2 What the stored string carries

4.2 one test password hashed twice with Argon2id (hash-wasm), OWASP minimum
  both strings: $argon2id$v=19$m=19456,t=2,p=1$<salt>$<tag> · strings equal: false · both verify: true · wrong password verifies: false
  node:crypto argon2Sync, fed only the parameters and salt parsed from the string, gives the same tag: true
  bcrypt (bcryptjs): $2b$10$<salt+tag> · verifies: true
  scrypt N=2^17,r=8,p=1 needs 128*N*r = 134217728 bytes: node:crypto default maxmem -> ERR_CRYPTO_INVALID_SCRYPT_PARAMS · maxmem 256 MiB -> 32-byte key

A second implementation reproduces the tag from the string alone (step 9). OWASP's scrypt setting needs 128 MiB per hash (step 8).

4.3 bcrypt's 72 bytes

4.3 bcrypt cost 10, stored password 80 bytes
  differs only after byte 72: verifies true · differs at byte 72: verifies false · bcrypt.truncates(stored): true

Bytes past 72 are never read (step 11).

4.4 TOTP on a virtual clock

4.4 RFC 6238 Appendix B test vectors (8 digits, published test seeds)
  t=59         T=1        94287082 46119246 90693936 · all match: true
  t=1111111109 T=37037036 07081804 68084774 25091201 · all match: true
  t=1234567890 T=41152263 89005924 91819424 93441116 · all match: true
  6-digit code read at t=1900000010 (step 63333333), verifier accepts steps T-1..T and rejects a reused step
  typed 25 s later (server step 63333334): accepted (step -1)
  same code again 3 s after that: rejected (replay)
  typed 65 s later (server step 63333335): rejected (no match in window)

The implementation matches the RFC's vectors for SHA-1, SHA-256 and SHA-512. One step late passes; a reuse and a two-step-old code do not (step 14).

Picture it

Concept illustration for this lesson.
An illustration to help it stick. The mechanism is in the sections above.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 5

A shop's sign-in service stored every password with Argon2id m=19456, t=2, p=1. This morning ops changed the service setting to m=47104, t=1, p=1, another OWASP-listed configuration. The sign-in handler runs hash-wasm's argon2Verify({ password, hash: row.hash }); when that returns true and the row's parameters differ from the setting, it stores a new hash made with the setting. A customer whose row was written last year types the correct password.

What happens?

Pick one

Question 2 of 5

A wallet app hashes passwords like this:

A test account's password is a 44-character passphrase.

Which typed passphrases does sign-in accept?

Code for this question
const PEPPER = await vault.get('pw-pepper');           // 256 random bits, hex-encoded (a generated test value)const hash = await bcrypt.hash(PEPPER + password, 10);  // sign-in: bcrypt.compare(PEPPER + typed, row.hash)
Pick one

Question 3 of 5

A chat app adds TOTP with 30-second steps. Product sets two goals: a user who takes 20 seconds to type the code gets in, and a code that an onlooker copies from the user's screen is useless once the user has signed in with it.

Which verifier meets both?

Pick one

Question 4 of 5

A box office's staff registered passkeys last year on its portal, with RP ID portal.boxoffice.example. The portal has now moved to staff.boxoffice.example, and the server expects the new origin, but every staff sign-in fails before any assertion reaches the server.

Which change lets staff sign in with the passkeys they already have, with no new registration?

Pick one

Question 5 of 5

A catalog admin panel's hashing module:

An audit of a leaked backup finds five rows holding byte-identical hash strings.

Which is the bug?

Code for this question
const salt = crypto.randomBytes(16);export const hashPassword = (password) => argon2id({ password, salt, parallelism: 1,  iterations: 2, memorySize: 19456, hashLength: 32, outputType: 'encoded' });
Pick one

Go deeper

Pro tips
  1. Count your stored hashes by prefix. Group rows by the part before the salt ($argon2id$v=19$m=19456,t=2,p=1, $2b$10$, raw hex) to see which accounts still need a rehash or a forced reset. When: after raising a work factor or migrating from a legacy hash (steps 9–10).
  2. Set scrypt's memory limit on purpose. Node's crypto.scrypt refuses OWASP's N=2^17, r=8 under its default maxmem of 32 MiB; pass a maxmem above 128 × N × r. When: using scrypt from node:crypto (steps 7–8).
Source
  • OWASP Password Storage Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html · fetched 2026-09-28. "Fast hashing algorithms such as SHA‑256 are not suitable for password storage because they allow attackers to perform large numbers of guesses quickly" (step 6). Salting: "As the salt is unique for every user, an attacker has to crack hashes one at a time using the respective salt rather than calculating a hash once and comparing it against every stored hash"; "it is impossible to determine whether two users have the same password without cracking the hashes" (steps 3–5). Peppering: "the pepper should not be public and should not be stored along with the generated hash"; "Peppers are secrets and should be stored in "secrets vaults" or HSMs"; it "prevents an attacker from being able to crack any of the hashes if they only have access to the database" (step 12). Work factors: "The work factor is typically stored in the hash output"; upgrading: "wait until the user next authenticates, then re-hash their password with the new work factor", and "hashes may never be upgraded if the user doesn't log back into the application" (steps 9–10). Parameters: Argon2id "m=19456 (19 MiB), t=2, p=1"; scrypt "N=2^17 (128 MiB), r=8 (1024 bytes), p=1"; bcrypt "a minimum of 10"; "PBKDF2-HMAC-SHA256: 600,000 iterations"; developers "should prefer modern algorithms that are designed to resist both GPU-based and memory-based attacks" (steps 7–8). bcrypt: "a maximum length input length of 72 bytes for most implementations"; pre-hashing "can be dangerous because of null bytes in the hash output value and because of password shucking", and the summary form "bcrypt(base64(hmac-sha384(data:$password, key:$pepper)), $salt, $cost)". Valid as of 2026-09-28.
  • W3C Web Authentication Level 3 (W3C Recommendation, 25 August 2026): https://www.w3.org/TR/webauthn-3/ · fetched 2026-09-28. Valid as of 2026-09-28.
  • Node.js v24 API, crypto.scrypt: https://nodejs.org/docs/latest-v24.x/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback · fetched 2026-09-28. maxmem: "It is an error when (approximately) 128 * N * r > maxmem. Default: 32 * 1024 * 1024". Valid as of 2026-09-28.
  • Observed, with one stand-in: D1 runs in Chromium 153.0.8010.12. Its authenticator is Chromium's CDP virtual authenticator, not a hardware or platform one, and its hosts are *.localhost names standing in for the stem's .example domains.

Checked against the tools and docs as of 2026-09-28.