Password Attacks & Cryptography · intermediate · ~11 min
**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.
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.
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.
In authorized professional work this shows up constantly:
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.
Each idea below is a separate tool. Learn what it defends against, because they defend against different attacks and you need all of them.
hash(salt + password) together with salt. At login you re-read the salt, recompute, and compare.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
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.
Passwords need a purpose-built approach. General-purpose fast hashes are exactly the wrong choice.
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 work factor is the dial that keeps slow hashes slow.
These are serious findings, because a database leak then becomes mass account compromise:
Recommendation: migrate to Argon2 or bcrypt with per-password salts and an appropriate work factor.
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.
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.
# 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.
# 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.
Walking through the secure path (secure_store.py + verify_fix.py):
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.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.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."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.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.
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" |
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$.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.$ 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.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?
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):
/login, /password-reset), and the security decision — AUTH_SUCCESS, AUTH_FAILURE, ACCOUNT_LOCKED, MFA_CHALLENGED, PASSWORD_CHANGED, HASH_REHASHED.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).Never log:
Events that signal abuse:
AUTH_FAILUREs for one account (brute force) or one password across many accounts (credential stuffing / password spraying).ACCOUNT_LOCKED, or logins succeeding from unusual geographies right after a public breach dump.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.
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.
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.
$2b$12$..., $argon2id$v=19$m=65536,t=3,p=1$..., and a readable word, state the likely scheme and whether it is acceptable.$-prefix are strong signals. No prefix + hex = raw fast hash.store_bad / store_good.verify across at least three settings (e.g. bcrypt cost 8/10/12, or Argon2 time/memory steps). Record the timings.$argon2id$/higher-cost string stored, and a log line noting the upgrade.check_needs_rehash / needs_update.sha1(password) to Argon2id, then prove it with tests.PEPPER=<development-placeholder>).$argon2id$ string is not proof its parameters are adequate.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.