L18 · Storing Passwords Correctly
Module 3: Cryptography Essentials · ~20 min · Lesson 18 of 71
🎯 What you'll learn
- Why a plain or salted SHA-256 of a password falls to a single gaming GPU
- What a salt does (and doesn't do), and why slow, memory-hard functions like Argon2id win
- The current OWASP parameters and NIST SP 800-63B-4 password rules
- How credential stuffing works, and the defenses you can put at the API gateway and in your service
🔥 Why this matters
In 2012, attackers stole LinkedIn's password database and posted about 6.5 million hashes online. They were SHA-1 with no salt, and a large share was cracked within days. In 2016 the full set surfaced: about 117 million accounts. Nobody broke SHA-1's math. They guessed, very fast, and every reused password then opened accounts on other sites.
1. Attackers don't reverse hashes, they guess
L14 showed that a hash is one-way. That stops nobody: with a stolen database the attacker runs it forwards, on guesses, on their own hardware, with no rate limit and no log. This is an offline attack.
stolen row: ana@example.com sha256 = a5c95e88...f9b8
attacker's GPU, no rate limit, no alerts
┌──────────────────────────────────────────────┐
│ guess "123456" ─sha256─▶ 8d96... no │
│ guess "Summer2025!" ─sha256─▶ 1f0c... no │
│ guess "Summer2026!" ─sha256─▶ a5c9... YES │
└──────────────────────────────────────────────┘
guesses come from leaked-password lists + rules
("add a year", "capitalise", "! at the end")
The problem is speed. SHA-256 is designed to be fast. One RTX 4090, a gaming card, does about 22 billion SHA-256 hashes per second in a published hashcat benchmark (newer drivers reportedly go faster). Every 8-character password made of lowercase letters and digits is 36⁸ ≈ 2.8 trillion guesses: about two minutes.
Your server hashes once per login; the attacker hashes billions of times per account. So make each hash deliberately expensive: 20 to 200 ms is invisible to a user and ruinous for the guesser.
2. Salts: one random value per password
Two customers pick Summer2026!. With plain SHA-256 they get the same hash, so cracking one cracks
both. Worse, the work can be done ahead of time: a lookup table maps hash → password for
billions of likely passwords, and a rainbow table is a compressed form that trades some compute
for far less storage. Built once, either cracks every unsalted database using that hash.
A salt is a random value (16 bytes is typical; NIST's floor is 32 bits) generated for each password and stored next to its hash. It's not secret.
WITHOUT salt WITH a per-user salt
ana sha256(pw) = a5c9.. ana H(pw, salt=9f3e..) = 71b0..
ben sha256(pw) = a5c9.. ben H(pw, salt=04d2..) = c8e5..
└── same hash: crack └── different: two jobs
once, own both
precomputed table? works precomputed table? useless,
it would need one per salt
What a salt does not do: slow down guessing one account. With ana's salt in hand, the attacker guesses just as fast as before. Only a slow function fixes speed.
3. Slow on purpose: Argon2id, scrypt, bcrypt, PBKDF2
A password hashing function is a hash built to be slow, with a work factor: a setting you can raise over time as hardware gets faster. The best ones are also memory-hard: each guess needs many megabytes of RAM. A GPU has thousands of cores but little fast memory per core, so memory-hardness cuts its advantage the most.
guesses/second, one RTX 4090 (hashcat benchmarks, rounded)
MD5 164,000,000,000 ████████████████
SHA-256 22,000,000,000 ██████████████
PBKDF2-SHA256 600k iter 15,000 ████
scrypt N=2^14 7,000 ███
bcrypt cost 10 6,000 ███
(bar length ≈ log scale: each block ≈ 10x)
The PBKDF2 and bcrypt rows are scaled from the benchmark's lower settings (999 iterations, cost 5); scrypt is hashcat's default test, far below OWASP's N=2¹⁷. Treat all of these as orders of magnitude. The two-minute password above takes about 15 years per account at bcrypt cost 10.
The OWASP Password Storage Cheat Sheet's current minimums:
| Algorithm | OWASP minimum (pick one row) | Notes |
|---|---|---|
| Argon2id (first choice) | m=19 MiB, t=2, p=1 (or m=46 MiB, t=1; other equal trade-offs listed) | Memory-hard; Argon2 won the 2015 Password Hashing Competition |
| scrypt | N=2¹⁷ (128 MiB), r=8, p=1 | Memory-hard; use if Argon2id isn't available |
| bcrypt | cost ≥ 10 | "Legacy systems"; only the first 72 bytes count |
| PBKDF2-HMAC-SHA256 | 600,000 iterations | Not memory-hard; use when you need FIPS-140 validation |
bcrypt's 72-byte limit is a real trap. In 2024 Okta disclosed that it bcrypt-hashed
userId + username + password as a cache key: with a 52+ character username, the password fell
past byte 72 and wasn't checked (in a narrow no-MFA setup). Some libraries silently truncate;
Python's bcrypt 5.0 raises an error.
A pepper is a secret key kept outside the database (KMS or Secrets Manager, L19), mixed in with an HMAC, so a stolen database alone is useless. NIST says verifiers SHOULD add one, on top of a slow hash, never instead of it.
4. What NIST now says about password rules
NIST SP 800-63B-4 went final in July 2025, replacing the 2017 edition. For passwords you verify:
- Length beats complexity. At least 15 characters if the password is the only factor, 8 if it's part of MFA. Allow at least 64. Accept spaces and Unicode, and never truncate.
- No composition rules ("one capital, one symbol"). They produce
Password1!. - No forced periodic changes. Force a change only on evidence of compromise.
- Blocklist common, expected and breached passwords, and rate-limit failed attempts.
- Store with a salt of at least 32 bits and an approved, slow hashing scheme.
5. Credential stuffing: no cracking needed
Credential stuffing is replaying email/password pairs leaked from one site, by bot, against another. If a customer reused a password, your perfect Argon2id storage doesn't matter: the attacker has the real password.
1. site A breached ──▶ combo list: ana@example.com:Summer2026!
2. bots on thousands of IPs ──▶ POST /v1/auth/login (your API)
│
Kong: rate-limiting ─┤ per IP: slows the cheap bots
service: per-account ┤ failures, breached-pw check
MFA (L21) ───────────┘ right password still not enough
3. a hit ──▶ account takeover: payout details changed
Password spraying is the mirror image: one common password against many accounts, staying under per-account lockouts. Layer the defenses (L04): MFA (L21); gateway rate limits (Kong's, in the examples) plus per-account counters in the service, because bots rotate IPs; alerts on the failed-to-successful login ratio; and rejecting breached passwords at signup and reset.
6. When do you actually store passwords?
In many organizations most logins go through SSO: an identity provider (a corporate IdP, or IAM Identity Center for AWS access) checks the password and services see tokens (L23, L24). Stored passwords still turn up in:
- Legacy apps with their own
userscollection. - Kong basic-auth. Open-source Kong stores SHA-1 of password plus consumer ID (per its source): fast, so use only long random values.
- Credentials you must present (a Mongo password). You need the original, so you can't hash it: it's a secret to keep encrypted in Secrets Manager (L19).
- API keys you issue, which are high-entropy and play by different rules (second deep dive).
🧪 Worked example: upgrading a Mongo users collection
The vulnerable version, as found in a legacy partner portal:
# ❌ unsalted, fast: same as LinkedIn 2012, with SHA-256
doc = {"_id": "u-1001", "email": "ana@example.com",
"pwd_sha256": hashlib.sha256(pw.encode()).hexdigest()}
The fix: Argon2id via argon2-cffi, with a rehash-on-login upgrade so nobody needs a reset.
import hashlib, hmac
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher(time_cost=2, memory_cost=19456,
parallelism=1) # OWASP: 19 MiB, t=2
def set_password(users, user_id, pw):
users.update_one({"_id": user_id},
{"$set": {"pwd": ph.hash(pw)},
"$unset": {"pwd_sha256": ""}})
def login(users, email, pw):
u = users.find_one({"email": email})
if u is None:
ph.hash(pw) # same cost: no timing hint
return False
if "pwd_sha256" in u: # legacy account
old = hashlib.sha256(pw.encode()).hexdigest()
if not hmac.compare_digest(old, u["pwd_sha256"]):
return False
set_password(users, u["_id"], pw) # upgrade now
return True
try:
ph.verify(u["pwd"], pw)
except VerifyMismatchError:
return False
if ph.check_needs_rehash(u["pwd"]): # params raised?
set_password(users, u["_id"], pw)
return True
After ana's next login her document holds one self-describing string (salt included):
"pwd": "$argon2id$v=19$m=19456,t=2,p=1$8zl6Icca41a9Dk5H...$5ULR..."
algorithm ver parameters salt (b64) hash
check_needs_rehash makes the work factor adjustable: raise memory_cost next year and users are
upgraded as they log in. Users who never log in keep the weak hash; the first deep dive fixes that.
🛠 Try it (optional, ~5 min)
Part 1: feel the difference. Only Python's standard library, nothing sent anywhere:
python3 - <<'EOF'
import hashlib, os, time
pw, salt = b"correct horse battery staple", os.urandom(16)
def bench(name, fn, n):
t = time.perf_counter()
for _ in range(n):
fn()
per = (time.perf_counter() - t) / n
print(f"{name:22} {per*1000:9.4f} ms {1/per:11,.0f}/s")
bench("sha256", lambda: hashlib.sha256(salt + pw).digest(), 10**6)
bench("pbkdf2-sha256 600k", lambda: hashlib.pbkdf2_hmac(
"sha256", pw, salt, 600_000), 5)
bench("scrypt N=2^17 r=8 p=1", lambda: hashlib.scrypt(
pw, salt=salt, n=2**17, r=8, p=1, maxmem=2**28), 5)
EOF
Output on a recent Apple-silicon laptop (one CPU core, Homebrew Python 3):
sha256 0.0002 ms 5,008,210/s
pbkdf2-sha256 600k 41.8450 ms 24/s
scrypt N=2^17 r=8 p=1 197.5856 ms 5/s
Five million SHA-256 per second on one core, even through Python's loop; a GPU does thousands of
times more. The OWASP settings cost 40 to 200 ms each. Apple's built-in /usr/bin/python3 (3.9,
LibreSSL) has no hashlib.scrypt; use Homebrew or python.org Python. With argon2-cffi and
bcrypt installed in a venv, the same laptop measured Argon2id (19 MiB, t=2) at 15 ms and bcrypt
cost 10 at 43 ms.
Part 2: a k-anonymity breach check. Have I Been Pwned's Pwned Passwords API checks a password without seeing it: send the first 5 hex characters of its SHA-1, compare the returned suffixes locally. Use a sample password:
P=$(printf '%s' 'password' | shasum -a 1 | cut -c1-40 | tr a-f A-F)
curl -s -H 'Add-Padding: true' \
"https://api.pwnedpasswords.com/range/${P:0:5}" | grep "${P:5}"
1E4C9B93F3F0682250B6CF8331B7EE68FD8:52372427
Only 5BAA6 left the machine; about 2,000 suffixes came back (padding adds fake :0 rows).
password has been seen over 52 million times in breaches (on 2026-11-06). A signup or reset
endpoint can run this same check.
🧠 Key takeaways
- Attackers guess offline; a fast hash (MD5, SHA-1, SHA-256) lets them guess billions per second.
- Salts stop precomputed tables and shared cracking, but don't slow guessing one account.
- Use Argon2id (19 MiB, t=2, p=1 or stronger); scrypt, bcrypt cost ≥ 10 or PBKDF2 600k if you must.
- NIST 800-63B-4: length over complexity, no forced rotation, blocklist breached passwords.
- Credential stuffing uses real passwords from other breaches: MFA, rate limits, breach checks.
📖 Words to know
| Term | Meaning |
|---|---|
| Offline attack | Guessing passwords against a stolen hash on the attacker's own hardware, with no rate limit |
| Salt | A random, non-secret value stored with each hash so identical passwords hash differently |
| Rainbow table | A compressed precomputed table for reversing unsalted hashes of likely passwords |
| Password hashing function | A deliberately slow hash with a tunable cost, e.g. Argon2id, scrypt, bcrypt, PBKDF2 |
| Work factor | The tunable cost (iterations, memory, bcrypt cost) of a password hash |
| Memory-hard | Each computation needs lots of RAM, which blunts GPU and ASIC advantages |
| Pepper | A secret key, kept outside the database, mixed into every password hash |
| Credential stuffing | Replaying username/password pairs leaked elsewhere against your login |
| k-anonymity | Sending only part of a value (a hash prefix) so the server can't tell which one you hold |
⏭ Next time
L19: key management and secrets. Where the pepper, the Mongo password a service must present, and every encryption key should live: KMS, envelope encryption, and rotation.
Upgrading a legacy password database without a reset
The problem with waiting for logins
Rehash-on-login (the worked example) only upgrades people who log in. In a partner portal with 2 million accounts, maybe 300,000 log in each quarter. The other 1.7 million SHA-256 hashes sit there for years. Those dormant accounts are exactly what a breach exposes, and their owners often reused the password elsewhere.
You can't rehash them properly without the passwords. But you don't need the password to make the
stored value slow: feed the old hash into the new function. This is called wrapping (or
layering): argon2id(sha256(password)).
Three states
┌───────────┐ batch job, today ┌──────────────────────┐
│ LEGACY │ input = old hash │ WRAPPED │
│ sha256(pw)├────────────────────▶│ argon2id(sha256(pw)) │
└───────────┘ no password needed └──────────┬───────────┘
│ next successful
│ login: we have pw
▼
┌──────────────────────┐
│ DIRECT │
│ argon2id(pw) │
└──────────────────────┘
login check per state:
WRAPPED: verify(stored, sha256(pw)) then rehash to DIRECT
DIRECT: verify(stored, pw)
After the batch job, every account is protected by Argon2id's cost, the same day. Logins then move people to direct hashes, which OWASP recommends you do rather than leaving them wrapped.
🧪 Worked example: the batch job
from pymongo import UpdateOne
batch = []
cur = users.find({"pwd_sha256": {"$exists": True}},
{"pwd_sha256": 1})
for u in cur:
old = u["pwd_sha256"]
batch.append(UpdateOne(
{"_id": u["_id"], "pwd_sha256": old}, # race-safe
{"$set": {"pwd": ph.hash(old),
"scheme": "argon2id(sha256)"},
"$unset": {"pwd_sha256": ""}}))
if len(batch) == 1000:
users.bulk_write(batch, ordered=False)
batch = []
if batch:
users.bulk_write(batch, ordered=False)
The filter includes the old hash, so if a user logged in and got a direct hash while the job ran,
that update simply matches nothing. Budget for the cost: 2 million × 15 ms is about 8 CPU-hours, so
split the _id range across several workers (an Airflow task per range works well) and run it off
peak, away from the login pods.
The login code gains one branch, and set_password now also writes "scheme": "argon2id":
if u.get("scheme") == "argon2id(sha256)":
inner = hashlib.sha256(pw.encode()).hexdigest()
try:
ph.verify(u["pwd"], inner)
except VerifyMismatchError:
return False
set_password(users, u["_id"], pw) # WRAPPED -> DIRECT
return True
Deploy order matters: first ship login code that understands all three states, then run the batch. Doing it the other way round locks out everyone the job has touched.
In Java, Spring Security's DelegatingPasswordEncoder has the same idea built in: each hash is
stored with a prefix such as {bcrypt} naming its algorithm, and upgradeEncoding() tells you when
to rehash on login.
What wrapping doesn't fix
- Password shucking. OWASP warns that layering "can make the hashes easier to crack". If the inner hash is unsalted SHA-256 and the attacker holds SHA-256 hashes from another breach, they can run those through your Argon2id instead of guessing passwords. A match proves reuse; they then crack the fast inner hash elsewhere. Moving users to DIRECT closes this, so set a deadline.
- Copies of the old hashes. Old Mongo backups and EBS snapshots, the oplog, a nightly export to S3, a data-lake table, a debug log line. Find and expire them, or the migration protects only one copy of the data.
- Accounts that never return. After 6 to 12 months, expire the remaining wrapped passwords and require a reset by email, or disable the accounts.
🛠 Try it (~5 min, thought exercise)
For one app you know that has local logins, answer: where are the hashes, which algorithm, and how many copies exist (backups, exports, replicas in other regions)? Then sketch which of the three states each copy would be in a week after a migration.
🧠 Extra takeaways
- Rehash-on-login alone leaves dormant accounts on the weak hash for years.
- Wrap old hashes in Argon2id now; replace them with direct hashes at the next login.
- Make the batch job race-safe, and deploy the reader before the writer.
- Old backups and exports still hold the weak hashes; the migration isn't done until they're gone.
| Term | Meaning |
|---|---|
| Wrapping (layering) | Hashing an existing hash with a stronger function, e.g. argon2id(sha256(pw)) |
| Rehash on login | Replacing a stored hash with a stronger one when the user next proves the password |
| Password shucking | Testing hashes leaked elsewhere against the outer layer of a wrapped hash |
API keys and tokens: why high-entropy secrets get a different treatment
The tempting mistake
You're issuing API keys to partners for /v1/quotes. Fresh from today's lesson, you hash each key
with Argon2id. Two things break:
- Lookup. A request arrives with a key. With a random salt per key, you can't compute the stored value until you know which row it is, so you'd have to try every row.
- Cost. At 2,000 requests per second, 15 ms and 19 MiB each is 30 CPU cores spent on hashing, for no gain.
Why "no gain": entropy
Slow hashing helps because human passwords live in a small, predictable space; it multiplies the cost of searching it. A key made of 32 bytes from a secure random generator (L20) has 2²⁵⁶ possible values. Even a 128-bit key, at a trillion guesses per second, takes about 10¹⁹ years to search. Making each guess a million times slower changes nothing that matters.
| Human password | Generated API key | |
|---|---|---|
| Chosen by | A person | secrets.token_urlsafe(32) |
| Search space | Wordlists, small | 2²⁵⁶, astronomically large |
| Reused elsewhere? | Often | Never (it's unique) |
| Store as | Argon2id, salted | SHA-256 or HMAC-SHA256, indexed |
| Checked | A few times a day | On every request |
So for high-entropy secrets, a fast hash is fine, no salt needed, and you can index it.
🧪 Worked example: issuing and checking a key
ISSUE (once) CHECK (every request)
secret = token_urlsafe(32) X-API-Key: exk_live_7Qm...
key = "exk_live_" + secret │
│ ▼
├─▶ shown to partner ONCE h = HMAC(pepper, key)
│ │
└─▶ Mongo api_keys: ▼
{ h: HMAC(pepper, key), find_one({"h": h})
partner: "P-0042", (unique index on h)
scopes: ["quotes:read"], │
expires: 2027-11-06 } ▼
found, not expired? allow
import hmac, hashlib, secrets
def new_key(pepper: bytes):
key = "exk_live_" + secrets.token_urlsafe(32)
h = hmac.new(pepper, key.encode(), hashlib.sha256)
return key, h.hexdigest() # give key, store digest
def lookup(api_keys, pepper: bytes, presented: str):
h = hmac.new(pepper, presented.encode(), hashlib.sha256)
return api_keys.find_one({"h": h.hexdigest()})
Plain SHA-256 is acceptable here; the HMAC with a pepper from Secrets Manager (L19) is a cheap
extra so a dump of the collection alone can't even confirm a guessed key. The made-up prefix
exk_live_ costs nothing and helps a lot: secret scanners (GitHub secret scanning, gitleaks) can
spot a leaked key in a repo or log by its pattern. GitHub's own tokens use prefixes like ghp_
plus a checksum for exactly this reason.
Where this shows up in a typical stack
- Kong key-auth. In open-source Kong the key-auth plugin's schema stores the key itself, not a hash, so a Kong database or decK dump holds live keys. Treat those dumps as secrets, and if you run Kong, check whether your edition offers field encryption.
- Kong basic-auth. Stored as SHA-1 with the consumer ID as salt: a fast hash. Safe only for long random credentials, which is exactly this deep dive's case. Never for passwords humans chose.
- Reset tokens, refresh tokens, session IDs. All high-entropy, all fine to store as a fast hash (L20's reset-token example does this).
The exception that proves the rule
The argument holds only for values generated by a secure random generator with at least 128 bits. A 6-digit SMS code has 1,000,000 values: any hash of it is cracked in milliseconds. Short codes are protected by expiry and attempt limits, not by hashing. And a "key" a human made up is a password.
🛠 Try it (~3 min)
Run python3 -c "import secrets; print(secrets.token_urlsafe(32))" a few times. Each output is 43
characters from 32 random bytes. Compare that with the 8-character password from section 1 and
work out the ratio of search spaces (2²⁵⁶ vs 36⁸).
🧠 Extra takeaways
- Slow hashing protects small search spaces; random 128+ bit keys don't have one.
- Store API keys as SHA-256 or HMAC-SHA256 (pepper), with a unique index, and show them once.
- Prefix your keys so scanners can find leaks.
- Short codes (OTPs) need expiry and attempt limits; hashing can't save them.
| Term | Meaning |
|---|---|
| Entropy | How unpredictable a value is, in bits; n random bits means 2ⁿ possibilities |
| High-entropy secret | A secret generated randomly with enough bits (128+) that guessing is hopeless |
| Secret scanning | Automatically searching code and logs for patterns that look like credentials |
The quiz for this lesson and the Tests section need JavaScript.