CyberNugget

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 users collection.
  • 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.

Deep dive 1

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
Deep dive 2

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:

  1. 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.
  2. 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.