Password Attacks & Cryptography · intermediate · ~11 min

Password storage: salts, slow hashes, and work factors

**What you will learn** - Explain why passwords must never be stored in plaintext or with fast general-purpose hashes. - Describe how a per-password **salt**, a **slow memory-hard KDF** (Argon2/scrypt/bcrypt), a **work factor**, and an optional **pepper** each reduce the damage of a database breach. - Read a stored credential string and tell whether it is safe (unique salt + slow KDF) or a finding (plaintext, fast hash, or shared salt). - Choose and tune a work factor so one verification takes roughly tens of milliseconds, and raise it over time. - Verify a fix: confirm identical passwords produce different stored hashes, that wrong passwords are rejected, and that correct passwords still authenticate. - Write up weak password storage as a finding with severity, safe evidence, and a concrete remediation-plus-retest plan.

Overview

Security objective. The asset you are protecting is your users' passwords — and, because people reuse passwords, their accounts on other services too. The threat is an attacker who has already obtained a copy of your credential store (a database leak, a stolen backup, an insider dump, or SQL injection). Your goal in this lesson is to make that stolen store as close to useless as possible: even holding every row, the attacker should not be able to recover the original passwords at any practical speed. You will learn to detect unsafe storage and prevent it.

This builds directly on your prerequisite, "Hashing: integrity, identification, and its limits." There you learned that a cryptographic hash is a one-way function: easy to compute forward, infeasible to invert, and that identical inputs always produce identical outputs. Password storage takes those exact properties and confronts their limits. A plain fast hash is too easy to compute forward — an attacker can compute it billions of times per second — and "identical inputs give identical outputs" leaks which users share a password and enables precomputed lookup tables. Password storage fixes both limits: a salt breaks the identical-input problem, and a slow key derivation function (KDF) breaks the too-fast-to-compute problem.

The short version: store every password as a unique per-password salt plus the output of a slow, memory-hard KDF — prefer Argon2, then scrypt, then bcrypt — tuned with a work factor so each guess is deliberately expensive. Optionally add a pepper: a secret kept outside the database. Plaintext passwords, fast unsalted hashes (MD5, SHA-1, SHA-256), and a single global salt are all serious findings, because they turn a database leak into mass account takeover.

Why it matters

Password storage decides what a database breach actually costs. The breach itself is often unavoidable; the question is what the attacker gets when it happens.

  • Done well, a leak exposes salted Argon2/bcrypt hashes that are slow and expensive to attack. Strong passwords may never be recovered; weak ones cost real time and money per guess, buying you a window to force resets.
  • Done poorly, a leak of plaintext or fast unsalted hashes becomes near-instant mass account takeover — and, because of password reuse, takeover of your users' email, banking, and work accounts.

In authorized professional work this shows up constantly:

  • Penetration testers and code reviewers flag fast/unsalted/plaintext storage as a recurring high-impact finding and recommend a salted slow KDF with a sensible work factor.
  • Incident responders triage a breach by asking first: how were the passwords stored? The answer determines whether you must force-reset every account immediately.
  • Backend and platform engineers own the migration to Argon2/bcrypt and the periodic work-factor increases as hardware gets faster.
  • Compliance and audit map this to standards such as OWASP ASVS and NIST SP 800-63B, which explicitly require salted, computationally-expensive password storage.

Being able to look at a credential column and say "this is safe" or "this is a critical finding, here is the fix and how we retest it" is a skill that pays off on both the offensive and defensive sides of the job.

Core concepts

Each idea below is a separate tool. Learn what it defends against, because they defend against different attacks and you need all of them.

1. Salt

  • Definition. A unique, random value generated fresh for each password and stored alongside its hash (the salt is not secret).
  • Plain explanation. The salt is mixed into the password before hashing, so the same password produces a different hash for every user.
  • How it works. You store hash(salt + password) together with salt. At login you re-read the salt, recompute, and compare.
  • When / when not. Always use a salt for password storage. It is not a secret and does not need to be — never rely on hiding the salt for security.
  • What it defends against. Precomputation. A rainbow table is a giant precomputed lookup from hash back to password. Salting makes every user's hash depend on random bytes the attacker could not have precomputed, so a generic table is worthless. It also hides the fact that two users chose the same password.
  • Pitfall. A single global salt shared by all users (sometimes miscalled a pepper) defeats most of the benefit: the attacker precomputes once against that one salt and cracks everyone. The salt must be per-password.

2. Slow, memory-hard KDF

  • Definition. A key derivation function built specifically for passwords — Argon2 (preferred, especially Argon2id), scrypt, or bcrypt — deliberately expensive in CPU time and (for Argon2/scrypt) memory.
  • Plain explanation. Instead of one fast hash, the KDF does a large, tunable amount of work per guess, so a single verification is cheap for you but a billion guesses is ruinous for an attacker.
  • How it works. Internally it iterates and, for memory-hard designs, fills a large block of RAM that must be present to compute the result. GPUs and ASICs get billions of fast hashes per second but choke on high memory-per-guess.
  • When / when not. Use for passwords and other low-entropy human secrets. Do not use a fast hash (SHA-256, MD5) for password storage; do not use a slow KDF where you need a fast hash (file integrity, HMAC).
  • What it defends against. Offline brute force / dictionary attacks against a leaked store. It turns "billions of guesses/second" into "thousands or fewer."
  • Pitfall. Rolling your own "slow hash" by looping SHA-256 a few thousand times is weaker than a vetted KDF and easy to get wrong. Use a maintained library.

3. Work factor (cost parameters)

  • Definition. The tunable dial(s) that set how expensive one KDF evaluation is — bcrypt's cost (rounds = 2^cost), Argon2's time, memory, and parallelism.
  • Plain explanation. It lets you pick how many milliseconds a single guess costs, trading user login latency for attacker pain.
  • How it works. Raising the factor multiplies the attacker's total effort with no change to your code. A common target is roughly tens of milliseconds (often cited as ~250 ms upper bound for interactive login) per verification on your production hardware.
  • When / when not. Set it as high as your latency and capacity budget allow, and raise it over time as hardware speeds up. Do not set it so high that login times out or lets an attacker exhaust your CPU (a denial-of-service risk).
  • Pitfall. Picking a value once and never revisiting it. A cost that hurt attackers in 2015 is cheap today. Schedule periodic reviews.

4. Pepper (optional, defense in depth)

  • Definition. A single secret value added to every password, stored separately from the database — for example in an environment variable, a secrets manager, or a hardware security module (HSM).
  • Plain explanation. Even if the attacker steals the whole database, without the pepper they still cannot verify guesses.
  • How it works. You compute the KDF over the password combined with the pepper (best applied as a keyed step, e.g. an HMAC with the pepper as key, in addition to the KDF and salt — never instead of them).
  • When / when not. Use as an extra layer when you can genuinely store the secret apart from the DB. It does not replace salts or the KDF.
  • Pitfall. If the pepper leaks (same server, same repo, same backup as the DB) it adds nothing, and rotating a pepper is operationally hard. Treat it as bonus, not foundation.

Threat model

TRUST BOUNDARY 1: network edge          TRUST BOUNDARY 2: database
(attacker sends login attempts)         (attacker has STOLEN a copy)

   +-----------+   entry: login    +-------------------+
   |  Attacker | ----------------> |  App / Auth code  |
   +-----------+   guesses         |  - generates salt |
        |                          |  - runs slow KDF  |
        | entry: DB dump /         |  - checks pepper  |
        v injection / stolen backup+---------+---------+
   +------------------+                      |
   | STOLEN DB COPY   | <--------------------+ stores:
   | user | salt |    |   ASSET PROTECTED:     salt (not secret)
   | KDF-hash         |   the PASSWORDS        kdf_hash
   +------------------+   (and reused creds)   [pepper NOT here]
                                               ^
   Pepper lives OUTSIDE this boundary --------/ (env / HSM / secrets mgr)

The key insight: the salt and KDF hash live inside the database the attacker steals — they must protect the password even when fully exposed. The pepper is the one secret that lives outside that boundary.

Knowledge check

  1. What asset is protected here, and why does password reuse widen the impact beyond your own app?
  2. Where is the trust boundary in this design — i.e., what does the attacker already hold when your storage scheme has to do its job?
  3. What insecure assumption makes a fast unsalted SHA-256 store crackable in seconds — and which single change (salt vs. slow KDF) fixes precomputation, and which fixes raw guessing speed?

Syntax notes

The safe pattern is always: read salt from storage → run the same slow KDF → constant-time compare. Below is the shape in pseudocode plus a real library call. All of this is lab-safe (no real secrets, localhost only).

# Registration (store)
salt      = random_bytes(16)               # unique per password, not secret
stored    = argon2id(password, salt, time=3, mem=64MiB, parallelism=1)
save(user, stored)   # modern libs pack algo+params+salt+hash into ONE string

# Login (verify)
stored    = load(user)
ok        = argon2id.verify(stored, password)   # re-derives params & salt from the string
# ok is true only if the password matches; comparison is constant-time

Modern KDF libraries encode everything you need to verify inside one self-describing string, so you do not manage the salt by hand. A bcrypt hash looks like:

$2b$12$Q9m5....22-char-salt....hash-bytes
 ^   ^  ^
 |   |  +-- base64 salt + hash (packed together)
 |   +----- cost = 12  (this is the WORK FACTOR: 2^12 rounds)
 +--------- algorithm identifier (bcrypt)

An Argon2 hash is similarly self-describing:

$argon2id$v=19$m=65536,t=3,p=1$<base64 salt>$<base64 hash>
  ^            ^                  ^
  algorithm    memory/time/parallelism = WORK FACTOR   salt (per password)

Because the parameters travel with the hash, you can raise the work factor for new passwords while old ones still verify — the verify call reads each row's own parameters.

Lesson

Passwords need a purpose-built approach. General-purpose fast hashes are exactly the wrong choice.

Why not a plain (fast) hash?

SHA-256 is designed to be fast. If an attacker leaks the database, they can compute billions of guesses per second on a GPU. Fast hashing makes cracking easy.

The right way

  • Salt. A unique random value per password, stored next to the hash. It defeats precomputation (rainbow tables) and ensures identical passwords hash differently.
  • Slow, memory-hard KDFs. Use Argon2 (preferred), scrypt, or bcrypt. They are deliberately expensive, with a tunable work factor and memory cost. This turns billions of guesses per second into thousands per second.
  • Pepper (optional). A secret added to all passwords and stored separately (for example, in an HSM or environment variable). A database leak alone is then not enough to crack hashes.

Work factor

The work factor is the dial that keeps slow hashes slow.

  • Tune the cost so a login takes about tens of milliseconds today.
  • Raise it over time as hardware gets faster.

What you'll find (and report)

These are serious findings, because a database leak then becomes mass account compromise:

  • Plaintext passwords.
  • Fast unsalted hashes (MD5, SHA-1, SHA-256).
  • A single global salt shared by all passwords.

Recommendation: migrate to Argon2 or bcrypt with per-password salts and an appropriate work factor.

Code examples

The example is in Python using the maintained passlib and argon2-cffi ecosystems, plus a plain-hashlib snippet to show the insecure baseline. This runs entirely on your own machine — no network, no real accounts.

(1) Insecure baseline

WARNING: intentionally vulnerable — use only in a local, isolated, authorized lab. Do not deploy.

# insecure_store.py  -- demonstrates what NOT to do
import hashlib

def store_bad(password: str) -> str:
    # Fast, unsalted hash. Billions of guesses/sec on a GPU.
    return hashlib.sha256(password.encode()).hexdigest()

if __name__ == "__main__":
    a = store_bad("correct horse battery staple")
    b = store_bad("correct horse battery staple")
    print("user1:", a)
    print("user2:", b)
    print("identical hashes for identical passwords? ", a == b)
    # -> True  (leaks that two users share a password; enables rainbow tables)

Expected output: both lines print the same 64-hex-character digest and the final line prints True. That equality is the vulnerability: no salt, and the hash is fast to compute.

(2) Secure fix

# secure_store.py  -- salted, slow, memory-hard KDF (Argon2id)
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

# Work factor: time_cost, memory_cost (KiB), parallelism.
# Tune so verify() takes ~tens of ms on YOUR hardware; raise over time.
ph = PasswordHasher(time_cost=3, memory_cost=64 * 1024, parallelism=1)

def store_good(password: str) -> str:
    # Library generates a fresh random salt internally and packs
    # algo + params + salt + hash into one self-describing string.
    return ph.hash(password)

def verify_good(stored: str, password: str) -> bool:
    try:
        ph.verify(stored, password)   # constant-time, re-reads salt+params
        return True
    except VerifyMismatchError:
        return False

if __name__ == "__main__":
    a = store_good("correct horse battery staple")
    b = store_good("correct horse battery staple")
    print("user1:", a)
    print("user2:", b)
    print("identical hashes for identical passwords? ", a == b)
    # -> False  (per-password salt makes them differ)

Expected output: two different $argon2id$... strings, and the final line prints False — identical passwords now store differently.

(3) Verify the fix (reject bad, accept good)

# verify_fix.py  -- proves the secure store behaves correctly
from secure_store import store_good, verify_good

def main() -> int:
    stored = store_good("S3cret-lab-pw!")

    # ACCEPT the correct password
    assert verify_good(stored, "S3cret-lab-pw!") is True, "correct pw rejected!"

    # REJECT wrong passwords
    assert verify_good(stored, "wrong") is False, "wrong pw accepted!"
    assert verify_good(stored, "S3cret-lab-pw") is False, "near-miss accepted!"

    # Two stores of the SAME password must differ (unique salt)
    assert store_good("same") != store_good("same"), "salt not unique!"

    print("ALL CHECKS PASSED: accepts good input, rejects bad input, salts unique")
    return 0

if __name__ == "__main__":
    raise SystemExit(main())

Expected output: ALL CHECKS PASSED.... If any assertion fails, the storage scheme is broken and the program exits non-zero — that is your mitigation-verification gate.

To run the lab: python3 -m pip install argon2-cffi in a throwaway virtual environment, then python3 verify_fix.py. Cleanup/reset: delete the venv and the three .py files when done; nothing was written to a real database or sent over a network.

Line by line

Walking through the secure path (secure_store.py + verify_fix.py):

  1. PasswordHasher(time_cost=3, memory_cost=64*1024, parallelism=1) — sets the work factor. memory_cost is in KiB, so 64*1024 = 64 MiB of RAM required per guess; that memory-hardness is what defeats GPU/ASIC farms. These three numbers are the dial you raise over time.
  2. ph.hash(password) — the library generates a fresh cryptographically-random salt, runs Argon2id with your parameters, and returns one string like $argon2id$v=19$m=65536,t=3,p=1$<salt>$<hash>. You never touch the salt directly.
  3. Calling store_good twice on the same password returns two different strings, because step 2 draws a new salt each time. This is why the demo prints False for "identical hashes."
  4. ph.verify(stored, password) — reads the algorithm, parameters, and salt back out of the stored string, recomputes the hash over the supplied password, and does a constant-time comparison. Constant-time means the compare does not return early on the first differing byte, so an attacker cannot time it to leak the hash.
  5. On mismatch, verify raises VerifyMismatchError, which we catch and turn into False. On match it returns without raising, so we return True.

Trace of verify_fix.py:

Step Input password Stored derived from Expected result Why
accept S3cret-lab-pw! S3cret-lab-pw! True same salt (from stored) + same password → same hash
reject wrong S3cret-lab-pw! False different password → different hash
reject S3cret-lab-pw (no !) S3cret-lab-pw! False one-char difference still fails; no partial credit
salt-unique same vs same two fresh stores not equal each hash() call draws a new salt

Contrast with the insecure baseline: hashlib.sha256(...) has no salt step and no work-factor step, so store_bad("x") == store_bad("x") is always True and each call costs microseconds — exactly the two properties an attacker exploits.

Common mistakes

Real mistakes seen in production code and how to fix them:

Wrong approach Why it is wrong Corrected How to recognise / prevent
Store the password in plaintext A single leak = every account, instantly; also readable by any DBA or backup thief Store argon2id/bcrypt output only Grep the schema/logs for readable passwords; a password column that returns readable text on SELECT is a critical finding
sha256(password) (fast, unsalted) GPU does billions/sec; identical passwords collide; rainbow tables apply Per-password salt + slow KDF Hash length matches a known fast digest (64 hex = SHA-256); no $argon2/$2b$ prefix
One global salt for all users Attacker precomputes once against that salt and cracks everyone Fresh random salt per password All rows share the same salt bytes; salt stored in code/config instead of per-row
md5(salt + password) MD5 is broken and fast; salting a fast hash still leaves it fast Argon2/bcrypt with a work factor Any use of MD5/SHA-1 in an auth path
Home-grown "slow" loop of SHA-256 Easy to get wrong; not memory-hard; no vetted parameters Use a maintained KDF library Custom crypto in the auth path — treat as a finding until reviewed
Set bcrypt cost once in 2016, never revisit Yesterday's cost is cheap on today's hardware Review and raise the work factor on a schedule; upgrade hashes on next login No documented cost-review cadence
Use == to compare hashes/secrets Byte-by-byte compare can leak via timing Use the library's constant-time verify (or hmac.compare_digest) Manual string equality on secret material
Treat pepper as a replacement for salt If DB leaks with the pepper, you have nothing; and pepper alone doesn't stop identical-password collisions Pepper is extra, on top of per-password salt + KDF Design docs that say "pepper instead of salt"

Debugging tips

Common failures and how to work through them:

  • verify() always returns False, even for the right password. You are probably re-hashing at verify time with a new salt and comparing strings, instead of calling the library's verify(stored, password). Fix: never recompute the salt yourself — pass the stored string back to verify, which extracts the salt. Debug by printing the stored string and confirming it still starts with $argon2id$/$2b$.
  • Login is painfully slow / server CPU spikes under load. Your work factor is too high for your hardware, or an attacker is hammering the login endpoint to burn CPU (a DoS via the KDF). Measure one verify with a timer; target roughly tens of ms. Add rate-limiting and lockout on the login endpoint so guesses cost the attacker, not you.
  • Stored value is 32/64 hex chars with no $ prefix. That is a raw fast hash (MD5/SHA-256), not a KDF. This is the bug, not a symptom — migrate the storage.
  • verify succeeds but you want to know if the hash is outdated. Argon2/passlib expose a check_needs_rehash/needs_update call; on a successful login with an old work factor, re-hash the plaintext (which you have in hand at that moment) and store the stronger hash.
  • Different environments produce incompatible hashes. Confirm the same library and version, and that parameters are read from the stored string, not hard-coded. Since parameters travel in the hash, verification should work across cost settings.

Questions to ask when it fails: Am I storing the salt per-row and reading it back, or regenerating it? Does the stored string self-describe its algorithm and parameters? Is my compare constant-time? How long does one verify take on production hardware? Is the login endpoint rate-limited so the KDF cost falls on the attacker?

Memory safety

Security & safety: detection and logging for password storage and authentication.

Good logging lets you spot credential-stuffing and cracking attempts before they succeed, and gives incident responders the evidence they need — without the logs themselves becoming the breach.

Log these (per authentication event):

  • Timestamp (with timezone), and a correlation/request id to tie related events together.
  • Source: client IP and, if available, an account/user id (or a stable hash of the username, not the password).
  • Resource / action: which endpoint (/login, /password-reset), and the security decision — AUTH_SUCCESS, AUTH_FAILURE, ACCOUNT_LOCKED, MFA_CHALLENGED, PASSWORD_CHANGED, HASH_REHASHED.
  • Result and reason code (e.g. FAILURE: bad_password, FAILURE: unknown_user — ideally the same generic message to the user so you don't reveal which usernames exist, while the reason is only in the log).
  • Rate-limit / lockout state, and whether the work factor was upgraded on this login.

Never log:

  • The password (plaintext or hashed), password-reset tokens, session cookies, MFA codes, the pepper, or any private key.
  • Full personal data you do not need. Minimize PII; a user identifier is enough.
  • Anything that would let a log-reader replay authentication.

Events that signal abuse:

  • Many AUTH_FAILUREs for one account (brute force) or one password across many accounts (credential stuffing / password spraying).
  • Bursts of failures from a single IP or ASN, or from many IPs against one account (distributed guessing).
  • A spike in ACCOUNT_LOCKED, or logins succeeding from unusual geographies right after a public breach dump.
  • Sudden CPU load on the auth service — possible KDF-based DoS.

How false positives arise: shared corporate NAT/VPN makes many legitimate users look like one IP; a mobile app with a wrong cached password retries and looks like brute force; a marketing password-reset email drives a legitimate spike in reset attempts; penetration test windows generate benign failures. Tune thresholds, allow-list known testing sources during authorized engagements, and correlate by account and source before alerting.

Reminder on limits: decoding or reading a stored hash tells you nothing about the password, and a stored $argon2id$ string being present does not by itself prove the work factor is adequate — verify the parameters. Never claim storage is "completely secure"; the goal is to make cracking slow and expensive, and to detect abuse quickly.

Real-world uses

Authorized real-world use case. You are doing a code review (with written permission) of a client's authentication service. You find the users table stores sha1(password). You reproduce the risk safely in an isolated copy — showing that identical test passwords produce identical hashes and that a fast hash allows enormous guess rates — and you deliver a plan: migrate to Argon2id with per-password salts and a tuned work factor, upgrade each user's hash transparently on their next successful login, and force a reset for any account whose weak hash may already have leaked. You then retest: confirm new hashes are $argon2id$, identical passwords now differ, wrong passwords are rejected, and login latency is within budget.

Best-practice habits

Habit Beginner Advanced
Validation / input Enforce a minimum length (NIST suggests 8+; longer is better) and screen against known-breached password lists Integrate a breach-corpus check at registration and reset; allow long passphrases and all Unicode; do not impose silly composition rules that push users to predictable patterns
Least privilege The app account can read/write only the auth table it needs Keep the pepper in a secrets manager/HSM the DB role cannot read; separate the credential store; limit who can query it and log those queries
Secure defaults Ship with Argon2id/bcrypt and a sane work factor out of the box Pin library versions, document the cost, and automate needs_rehash upgrades on login
Logging Log success/failure, source, timestamp, decision Correlate for stuffing/spraying, alert on anomalies, and rotate/protect logs; never log secrets
Error handling Same generic "invalid credentials" for unknown-user and bad-password Add rate-limiting, exponential backoff, account lockout with care for DoS, and MFA as an independent layer

Across the board: passwords are a defense-in-depth problem. Salt + slow KDF + work factor + pepper + rate-limiting + MFA + monitoring each cover a different attack; none is sufficient alone.

Practice tasks

All tasks are lab-only: run on your own machine, in a throwaway virtual environment, against test data you create. No real accounts, no network targets. Each ends by remediating and verifying.

Beginner 1 — Classify stored credentials

  • Objective. Given a list of stored credential strings, label each as safe or a finding.
  • Requirements. For samples like a 32-hex string, a 64-hex string, $2b$12$..., $argon2id$v=19$m=65536,t=3,p=1$..., and a readable word, state the likely scheme and whether it is acceptable.
  • Output. A table: sample → algorithm guess → verdict (safe / finding) → one-line reason.
  • Constraints. Do not attempt to crack anything; classification only.
  • Hints. Length and the $-prefix are strong signals. No prefix + hex = raw fast hash.
  • Concepts. Salt, slow KDF, fast-hash detection.
  • Defensive close. For every "finding," write the one-line remediation.

Beginner 2 — Prove the salt matters

  • Objective. Show empirically that salting changes identical-password behavior.
  • Requirements. Hash the same test password twice with an unsalted fast hash, then twice with Argon2id/bcrypt. Print both pairs.
  • Input/Output. Input: one fixed test string. Output: two equal digests (unsalted) vs two unequal strings (KDF).
  • Constraints. Local only; use a library for the KDF.
  • Hints. Reuse the lesson's store_bad / store_good.
  • Concepts. Per-password salt, identical-input problem.
  • Defensive close. State which real attack the difference defeats (rainbow tables).

Intermediate 1 — Tune a work factor

  • Objective. Pick a work factor that costs roughly tens of milliseconds on your hardware.
  • Requirements. Time one verify across at least three settings (e.g. bcrypt cost 8/10/12, or Argon2 time/memory steps). Record the timings.
  • Output. A small table: parameters → measured ms → chosen setting with justification.
  • Constraints. Do not exceed a latency budget you set (e.g. 250 ms); note the DoS trade-off.
  • Hints. Use a monotonic timer; average several runs; a warm cache matters.
  • Concepts. Work factor, latency vs. attacker-cost trade-off.
  • Defensive close. Document when you would raise it again and why.

Intermediate 2 — Transparent rehash-on-login

  • Objective. Upgrade a weak stored hash to a stronger one during a successful login, without asking the user to reset.
  • Requirements. Given a store using a low work factor (or a legacy scheme), on a correct password: verify, detect the hash is outdated, re-hash the supplied plaintext at the new cost, and replace the stored value.
  • Input/Output. Input: a test "old" hash + correct password. Output: a new $argon2id$/higher-cost string stored, and a log line noting the upgrade.
  • Constraints. Only rehash on a verified login (that's the only moment you legitimately hold the plaintext).
  • Hints. Look for check_needs_rehash / needs_update.
  • Concepts. Work-factor migration, least-disruption remediation, logging.
  • Defensive close. Verify old and new hashes both authenticate the same password, and that the new one is stronger.

Challenge — Design and defend a storage-migration plan

  • Objective. Produce a written remediation plan to move a lab app from sha1(password) to Argon2id, then prove it with tests.
  • Requirements. (a) A migration strategy that keeps existing users logging in (e.g. wrap old hashes, upgrade on login, or dual-verify). (b) Choose work-factor parameters with justification. (c) A finding write-up: title, severity (with reasoning about exploitability/impact — is this critical, and why?), affected component, safe reproduction in your lab, evidence, impact, likelihood, remediation, and retest steps. (d) An authorization checklist for the lab. (e) Cleanup/reset steps.
  • Constraints. Lab only; no real user data; placeholders for any secrets (PEPPER=<development-placeholder>).
  • Hints. Severity depends on exploitability, access required, and blast radius — not everything is critical; argue it. Remember: passing a scanner is not proof of security, and a present $argon2id$ string is not proof its parameters are adequate.
  • Concepts. Migration, work factor, finding template, detection/logging, verification.
  • Defensive close. The plan must end with a concrete retest that shows: identical passwords now differ, wrong passwords are rejected, correct passwords authenticate, and hashes are upgraded — plus what you would monitor post-migration.

Summary

Main concepts. Store every password as a unique per-password salt plus the output of a slow, memory-hard KDF — Argon2id (preferred), then scrypt, then bcrypt — tuned with a work factor so one verification costs roughly tens of milliseconds. Optionally layer a pepper kept outside the database. The salt defeats precomputation (rainbow tables) and the identical-password problem; the slow KDF and work factor defeat high-speed offline guessing; the pepper adds a secret the attacker doesn't get from the DB alone.

Key syntax/commands. ph.hash(password) to store (library packs algo+params+salt+hash into one $argon2id$... string); ph.verify(stored, password) for a constant-time check that re-reads the salt and parameters. Recognise schemes by prefix: $argon2id$/$2b$ = good KDFs; a bare 32/64-hex string = a fast hash and a finding.

Common mistakes. Plaintext storage; fast unsalted hashes (MD5/SHA-1/SHA-256); a single global salt; home-grown slow loops; comparing secrets with == instead of constant-time verify; setting a work factor once and never raising it; treating a pepper as a substitute for salting.

What to remember. Password storage decides what a breach costs. Assume the attacker already holds your database — the salt and hash live inside that boundary and must protect the password anyway. Log auth decisions (timestamp, source, resource, result, correlation id) but never log passwords, tokens, or the pepper; watch for credential stuffing and spraying. Reading a hash is not recovering a password; a present KDF string is not proof its parameters are adequate; nothing is ever "completely secure" — the goal is to make cracking slow, expensive, and detectable.

Practice with these exercises