Here comes the course's oldest and most important debt: how does MediNube store the passwords ana.perez and thousands of other users type in to log into portal.medinube.example? We brushed against it in 01-03 (tokens and entropy), sidestepped it on purpose in 02-04 ("deriving keys to encrypt is a different problem; authentication's turn comes in 03-03"), and the previous lesson left us at the door: fast hashes are fine for high-entropy secrets, and a human password is exactly the opposite. This lesson walks through the historical evolution of the mistake (plaintext → fast hash → hash with salt → deliberately slow functions), fixes the current recommendation — Argon2id with argon2-cffi — and lands it in the portal's full registration and login flow, including transparent migration of legacy hashes. It matters because it is, by far, the cryptographic failure with the most real victims: user database leaks are counted in the billions of credentials, and the difference between an annoying incident and a catastrophe is exactly what you're about to learn here.
Contents
- The threat scenario: your database is getting stolen
- Verifier vs. key: how this differs from 02-04
- The evolution of the mistake, stage by stage
- The right functions: bcrypt, scrypt, and Argon2id
- Argon2id in Python with
argon2-cffi - The modular format: the hash that carries everything inside it
- Practical case: registration and login in MediNube, migration included
- What NOT to do (even if it sounds like a good idea)
- Beyond the password: MFA and passkeys
The threat scenario: your database is getting stolen
The design starts by assuming the worst (golden rule 2: the enemy knows the system — and here, on top of that, has your disk). The scenario password storage is designed against is specific: the attacker obtains a full copy of the users table. Plenty of real-world routes: SQL injection, a misconfigured backup bucket, a developer's laptop, a disgruntled employee.
Two nuances that frame the problem well:
- We're not designing for the online login. Attempts against
portal.medinube.example's login form are throttled with rate limits and lockouts — an application-layer concern (rule 9). We're designing for the offline attack: the attacker, with the table on their own machine, tries candidates at their hardware's speed, with no limits and no logs. - The goal isn't that no password ever falls (whoever uses
123456always will), but to buy time and make the mass attack expensive: cracking the bulk of the table should cost years of GPU time instead of an afternoon, giving enough margin to detect the breach, force resets, and notify those affected. With health data involved, on top of that, a real breach triggers GDPR obligations (notifying the authority and those affected) — the usual reminder: MediNube is fictional, and a real system of this kind goes through security and compliance review before production.
Verifier vs. key: how this differs from 02-04
In 02-04 we used scrypt to turn Clínica Sol's administrator password into an AES key to encrypt a backup. Here we use the same family of functions... to encrypt nothing. The distinction deserves its own table, because confusing the two cases produces absurd designs:
| Deriving a key to encrypt (02-04) | Storing a verifier to authenticate (today) | |
|---|---|---|
| Goal | Get 32 usable bytes as a key | Be able to check "is this the password?" |
| Is the output stored? | No — the key is used and discarded | Yes — the verifier lives in the DB |
| Does the output unlock anything? | Yes: it decrypts the data | No: on its own it's useless |
| If an attacker steals it | Disaster: they decrypt the backup | They still have to attack: it's only a verifier |
| Needs external determinism | Yes: same parameters to re-derive | Self-contained: the parameters travel inside the hash |
| Typical function | scrypt/Argon2 as a KDF | bcrypt/scrypt/Argon2id as a password hasher |
The new idea is that of a verifier: a value that lets you check a submitted password but doesn't contain the password or substitute for having it. At login, MediNube recomputes the function over what the user typed and compares it against what's stored. Same slow math as 02-04, opposite purpose.
The evolution of the mistake, stage by stage
Every stage of this story is still alive in legacy production code. Worth knowing all of them, because you'll run into them.
Stage 0: plaintext
def save_password_BAD(username: str, password: str):
db.insert(username, password) # NO: the stolen DB IS the list of credentialsDB theft = every user's credentials, instantly. And since people reuse passwords, the damage spreads to their email and banking accounts. A well-known aggravating case: RockYou (2009, 32 million plaintext passwords) — its leaked file is today's standard attacker dictionary. If your application can show you your current password or email it to you, it's at this stage.
Stage 1: fast hash without salt
def save_password_BAD_v2(username: str, password: str):
db.insert(username, hashlib.sha256(password.encode()).hexdigest())It looks like it fixes things ("it's one-way") and fails on the two fronts you already know from 03-01:
- Speed. SHA-256 is designed to fly. Rough figures on a hobbyist's rig with a handful of modern GPUs: on the order of tens of billions of SHA-256 per second (and ~10x more for MD5). The entire RockYou dictionary against one hash: milliseconds. Every combination of 8 alphanumeric characters: a matter of hours.
- Global determinism, no salt. The same password produces the same hash in every row and at every company in the world. That enables rainbow tables — precomputed hash→password tables built once and reused against any DB — and, as a bonus, it gives away patterns: if three users share a hash, they share a password.
Stage 2: fast hash with salt
Here the salt from 02-04 reappears: a value that's unique per user, random (CSPRNG), not secret, stored alongside the hash and mixed with the password before hashing.
- What it fixes: it kills rainbow tables (you'd have to precompute a table for every possible salt) and makes identical passwords produce different hashes. The attack is no longer "the whole table at once": it's row by row.
- What it does NOT fix: speed. The attacker no longer precomputes, but still runs RockYou and its derivatives against each row at billions of attempts per second.
sha256(salt + password)still falls in droves against GPUs. (And watch out: it would still be the length-extension-vulnerable construction from 03-02 — a second reason not to build it by hand.)
Stage 3: deliberately slow functions
The conclusion the industry took decades to reach: against low-entropy secrets, the defender's only lever is the cost of each attempt. If verifying a password costs ~100 ms and a good chunk of RAM on purpose, the legitimate user doesn't even notice (one login per session), but the attacker who needs billions of attempts sees their budget multiplied by millions. It's exactly the same asymmetry as the slow KDFs from 02-04, now applied to verifiers. And with memory as the anti-GPU weapon: a GPU has thousands of cores, but not thousands of gigabytes of fast RAM to feed them.
flowchart LR
A["Stage 0<br/>plaintext"] -->|"'one-way' hash"| B["Stage 1<br/>SHA-256 without salt"]
B -->|"rainbow tables"| C["Stage 2<br/>SHA-256 + salt"]
C -->|"GPUs: still fast"| D["Stage 3<br/>deliberately slow:<br/>bcrypt / scrypt / Argon2id"]
The right functions: bcrypt, scrypt, and Argon2id
The three serious candidates, two of which you already know from 02-04:
- bcrypt (1999): the honorable veteran. Purely CPU-bound cost, regulated by an exponential cost factor (cost 12 ≈ 4096 internal rounds of its core; each +1 doubles the time). Still reasonable, with two caveats: it's not memory-hard (GPUs keep eating into its margin year after year), and it has a dangerous quirk: it only processes the first 72 bytes of the password — anything past that is silently ignored, and more than one implementation has "solved" this by truncating or badly pre-hashing. If you inherit bcrypt, it's not an emergency; for new code there's a better option.
- scrypt (2009): the first memory-hard one; you already used it for the exportable backup with
n=2^17, r=8, p=1. Perfectly valid as a password hasher too; its weak spot is having CPU and memory cost coupled into a single main parameter. - Argon2id (2015): winner of the Password Hashing Competition and the current recommendation (OWASP's too). Three independent dials — memory, iterations (time cost), parallelism — and the id variant combines GPU-attack resistance with side-channel-attack resistance. It's the one MediNube will adopt.
| bcrypt | scrypt | Argon2id | |
|---|---|---|---|
| Year / origin | 1999 | 2009 | 2015 (PHC winner) |
| Memory-hard | No | Yes | Yes |
| Parameters | cost factor | n, r, p (coupled) | memory, time, parallelism |
| Input limit | 72 bytes | None | None |
| In Python | bcrypt package |
pyca/cryptography, hashlib |
argon2-cffi |
| Verdict | Acceptable if you're already using it | A good choice | Recommended for new code |
Indicative Argon2id parameters for an application server in 2026 (and OWASP's starting point): 64 MiB memory, 3 iterations, parallelism 4, 16-byte salt, 32-byte output. The practical rule: aim for 50–200 ms per verification on your production hardware, and raise the memory as high as your server can tolerate under concurrent login load. These happen to be argon2-cffi's current defaults, which makes the code below refreshingly undramatic.
Argon2id in Python with argon2-cffi
pip install argon2-cffi (we introduced it in 02-04; now we put it to real use). The central piece is PasswordHasher:
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher() # Argon2id, 64 MiB, t=3, p=4 - sane defaults
# Sign-up: hash it
stored_hash = ph.hash("CorrectHorseBatteryStaple")
print(stored_hash)
# $argon2id$v=19$m=65536,t=3,p=4$YWxndW5zYWx0cnJyYQ$G3Kx0...
# Login: verify
try:
ph.verify(stored_hash, "CorrectHorseBatteryStaple") # OK: returns True
ph.verify(stored_hash, "wrong-password") # raises an exception
except VerifyMismatchError:
print("Incorrect password")Breakdown, piece by piece:
PasswordHasher()fixes the current policy's parameters. You can adjust them (PasswordHasher(memory_cost=131072, time_cost=3, parallelism=4), memory in KiB), but don't lower them below the defaults without a measured reason.ph.hash(password)generates a fresh 16-byte salt from the CSPRNG on its own and returns the complete hash as text. Notice we don't pass a salt in — impossible to forget or reuse one. It accepts astrdirectly (it handles UTF-8 for you).ph.verify(hash, password)re-runs Argon2id with the salt and parameters it reads from the hash itself and compares in constant time. With a wrong password it raisesVerifyMismatchError— the "exception, not a silent boolean" pattern you already saw withTamperedMedicalRecord(02-03): a verification failure must be impossible to ignore by accident.- Every call to
hashwith the same password gives a different result (fresh salt). That's why you can't "look up the hash in the DB" the way we did with tokens in 03-02: here you look up the row by username and verify its hash. Compare it to the verifier-vs-key table: this is a self-contained verifier, not a reproducible key.
The modular format: the hash that carries everything inside it
That string full of $ is the modular format (PHC string format), and understanding it prevents an entire class of mistakes:
$argon2id$v=19$m=65536,t=3,p=4$YWxndW5zYWx0cnJyYQ$G3Kx0...
│ │ │ │ │
│ │ │ │ └─ hash (Base64)
│ │ │ └─ salt (Base64)
│ │ └─ parameters: 65536 KiB, 3 iterations, parallelism 4
│ └─ algorithm version
└─ algorithm- The salt and the parameters travel inside the hash. You don't need
salt,iterations,algorithmcolumns in the users table: a single text column (~100 characters) holds it all.verifyreads what it needs right there. - The salt being visible isn't a flaw: as we established in 02-04, the salt is unique and random, but public. What protects the password is the function's cost, not the salt's secrecy.
- A powerful consequence: each row declares its own parameters, so a single table can have old hashes (
m=65536) and new ones (m=131072) coexisting, or even bcrypt ($2b$12$...) and Argon2id during a migration. This is crypto-agility (rule 8) applied to passwords, and it's the foundation of the next section. The piece that ties it together isph.check_needs_rehash(hash): it returnsTrueif the stored hash was generated with weaker parameters than thePasswordHasher's current policy.
Practical case: registration and login in MediNube, migration included
Full context of the thread: the portal is carrying a users table from stage 2 — salted SHA-256, the classic legacy setup. The code we found:
# legacy code - DO NOT use
import hashlib, os
def save_password_BAD(username: str, password: str):
salt = os.urandom(16)
h = hashlib.sha256(salt + password.encode("utf-8")).hexdigest()
db.save(username, scheme="sha256", salt=salt.hex(), hash=h)
def login_BAD(username: str, password: str) -> bool:
row = db.find(username)
h = hashlib.sha256(bytes.fromhex(row.salt) + password.encode()).hexdigest()
return h == row.hash # also, non-constant-time comparisonDiagnosis you already know: correct salt, but a fast function (stage 2) and an == comparison. The real challenge isn't writing the good version — it's migrating without resetting every user. We can't convert the hashes ourselves (we don't have the passwords, that's the whole point!), but there's one moment when every password legitimately passes through the server: login. The standard strategy is to migrate right then, user by user:
# medinube/auth.py - correct version, with transparent migration
import hashlib, hmac
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher() # current policy: Argon2id, 64 MiB, t=3, p=4
def register(username: str, password: str):
"""Sign-up of a new user: straight to Argon2id."""
db.save(username, scheme="argon2", hash=ph.hash(password))
def login(username: str, password: str) -> bool:
row = db.find(username)
if row is None:
ph.hash(password) # spend the same time even if the user doesn't exist
return False # (don't leak via timing that the username doesn't exist)
if row.scheme == "sha256": # legacy hash (stage 2)
h = hashlib.sha256(bytes.fromhex(row.salt) + password.encode()).hexdigest()
if not hmac.compare_digest(h, row.hash):
return False
# Correct password, and it's in memory: MIGRATE now
db.save(username, scheme="argon2", hash=ph.hash(password), salt=None)
return True
# Modern scheme
try:
ph.verify(row.hash, password)
except VerifyMismatchError:
return False
if ph.check_needs_rehash(row.hash): # policy hardened?
db.save(username, scheme="argon2", hash=ph.hash(password))
return TrueRead it slowly, because it condenses half the lesson:
register: three lines. All the complexity (salt, parameters, format) lives insideargon2-cffi. Nothing to invent (rule 1). Password quality policy (minimum length, not appearing in breach lists) belongs to the application layer and complements this, it doesn't replace it.- Non-existent user: a hash is still computed so the response takes as long as it would for a real user — otherwise the response time would leak which usernames exist (the same spirit as rule 6, applied at the application layer).
- Legacy branch: verified against the old scheme (with
compare_digest, fixing the==along the way), and if the password is correct, we take advantage of having it in memory to rehash it with Argon2id and overwrite the row. The user notices nothing; the row'sschemetag changes forever. Over time, thesha256branch empties out — and for stragglers who never come back, a forced email reset kicks in after a deadline (with the recovery token safely stored from 03-02, everything connects). check_needs_rehash: the same move, but pointed at the future. When you raisememory_costin 2029, old hashes will renew themselves on their own at every login. Crypto-agility by default, no traumatic migrations.- The explicit
schemefield plays the same role as the version byte in 02-03'sv1format: every piece of data declares how it's protected.
What NOT to do (even if it sounds like a good idea)
- Limiting password length ("16 characters max"). There's no cryptographic reason: the hash produces fixed output no matter how much goes in. A short limit only cuts into a diligent user's entropy. A generous anti-DoS limit (say, 1024 characters) is reasonable. And if you use bcrypt, remember its silent 72-byte truncation: that's a reason to pick Argon2id, not to ban long passphrases.
- Truncating or "normalizing" the password (trimming spaces, lowercasing "for usability"). Every transformation melts entropy and creates equivalence classes the attacker exploits for free.
- The misunderstood "pepper." A pepper is a global server secret added to the calculation (typically
HMAC(pepper, password)before the slow hash), stored outside the DB — in the secrets manager we'll see in 06-01. Done right, it adds a real layer: a stolen DB without the pepper isn't even attackable by dictionary. But it carries serious risks that leave it as "optional for mature teams": if the pepper is lost, every login dies with no possible recovery; rotating it is hard (you can't recompute the hashes without the passwords); and done badly (concatenating instead of HMAC-ing, storing it in the same DB) is theater. Argon2id first; a pepper only with solid secrets management. - Composing homemade schemes:
md5(sha1(password)), "my own Argon2 but with my own XOR first," double Argon2 "for extra security." Golden rule 1, once again: homemade combinations don't add strength, they add attack surface. UsePasswordHasheras-is. - Encrypting passwords instead of hashing them. Encryption is reversible by design: whoever has the key (or steals it along with the DB) recovers every password. A verifier must not be reversible even by MediNube itself — golden rule 7: the goal here is to verify, not to recover.
Beyond the password: MFA and passkeys
Let's close honestly about the limits: Argon2id protects the password in your database; it can do nothing if the user types it into a phishing site or reuses it on a site that's already leaked. That's why the state of the art adds layers: MFA (a second factor: TOTP, one-time codes) and, increasingly, passkeys (WebAuthn) — public-key-signature-based credentials where the server no longer stores any dictionary-attackable secret. We won't develop these in this course, but notice the detail: passkeys = digital signatures, the asymmetric cryptography that begins in the upcoming module. For MediNube, with health data involved, MFA for clinical staff wouldn't be optional in a real deployment.
Common Mistakes and Tips
- "I use SHA-256 with salt, I'm already at the good stage." No: the salt only kills precomputation. Without a cost per attempt you're still at stage 2, and GPUs chew through it. The control question: "how long does verifying one password take?" — if the answer is "no time at all," that's the problem.
- Storing the salt in a separate column and passing it to
argon2-cffi. A sign you're fighting the library:hash()generates the salt and embeds it in the modular format;verify()reads it from there. If your DB schema has asaltcolumn next to Argon2 hashes, something's being done by hand that shouldn't be. - Returning different errors for "user doesn't exist" and "wrong password" (or taking a different amount of time). User enumeration served on a plate. Same message, same timing.
- Eyeballing the parameters or copying them from a 2015 tutorial. Measure them on your production hardware, aiming for 50–200 ms, review the policy every few years, and let
check_needs_rehashpropagate any increase. - Migrating "when there's time" while leaving
login_BADin production. Migrating at login costs an afternoon of work and pays for itself the first time there's a breach. Genuinely high priority. - Tip: add passwords from RockYou's top list to your test suite and verify your application layer rejects them. The best Argon2id can't save
medinube2026from a ten-thousand-entry dictionary.
Exercises
Exercise 1. A leaked service's users table contains these three rows (same hash column). State which stage of the evolution each scheme is at, which attack affects it, and rank them from worst to best:
alice | 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8 bob | a1b2c3d4e5f6a7b8:9c87b1cbb62d5a7ac43276b8b12ab5a4adcb1e83e5c2e6f1... carol | $argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub...
Exercise 2. Without running it, work out what each line prints and why:
from argon2 import PasswordHasher
ph = PasswordHasher()
h1 = ph.hash("blue-cat-7")
h2 = ph.hash("blue-cat-7")
print(h1 == h2)
print(ph.verify(h1, "blue-cat-7"), ph.verify(h2, "blue-cat-7"))
ph2 = PasswordHasher(memory_cost=131072)
print(ph2.check_needs_rehash(h1))Exercise 3. MediNube acquires a clinic whose system stored passwords with bcrypt, cost factor 10 (format $2b$10$..., bcrypt package: bcrypt.checkpw(password_bytes, hash_bytes)). Write the extra branch of the login function that verifies those hashes and migrates them to Argon2id on the first successful login. Do you need a new table or a new salt column?
Solutions
Solution 1. alice: 64 hex characters = plain SHA-256, stage 1 (in fact it's sha256("password")): vulnerable to rainbow tables and to full-speed dictionary attacks; repeated hashes across users would give away shared passwords. bob: salt:hash format, stage 2 (fast hash with salt): immune to precomputation, but each row still falls at GPU speed with a dictionary. carol: Argon2id modular format, stage 3: every attempt costs 64 MiB and tens of milliseconds for the attacker too. Worst to best: alice < bob < carol. Exam detail: the visible salt in bob's and carol's rows is not a weakness.
Solution 2. h1 == h2 → False: every hash() call generates a fresh salt from the CSPRNG, so the same password produces different hashes (and that's desirable: it doesn't give away repeated passwords). Both verify calls → True True: each hash carries its own salt inside, and verification re-derives using it. ph2.check_needs_rehash(h1) → True: h1 declares m=65536 in its modular format, below ph2's policy (131072 KiB); in a real login this would trigger the transparent rehash.
Solution 3. Just one more branch is enough; no table or salt column needed — bcrypt also uses a modular format with the salt embedded ($2b$10$<salt><hash>):
import bcrypt
# ... inside login(), after finding the row:
if row.scheme == "bcrypt":
if not bcrypt.checkpw(password.encode("utf-8"), row.hash.encode("utf-8")):
return False
# correct: migrate to MediNube's current policy
db.save(username, scheme="argon2", hash=ph.hash(password))
return TruePoints worth noting: checkpw requires bytes (hence the .encode calls), it already compares in constant time internally, and the migration reuses the moment the password is legitimately in memory. Reminder about bcrypt's limit: if the portal allows passphrases longer than 72 bytes, the legacy $2b$ hashes only ever verified the first 72 — one more reason to complete the migration (Argon2id doesn't truncate).
Conclusion
The course's oldest debt is settled: MediNube's portal now stores Argon2id verifiers ($argon2id$..., with salt and parameters inside), registers users with three lines of PasswordHasher, verifies with verify + VerifyMismatchError, migrates legacy SHA-256 rows on the first successful login, and re-hardens itself automatically via check_needs_rehash. And the distinction from 02-04 is now clear: same family of slow functions, but there the output was a key that encrypts, and here it's a verifier that only checks.
This closes out Module 3 in full, and the three pieces fit together like a staircase: the hash (03-01) gives integrity when the fingerprint lives somewhere safe — verifiable exports, deduplication, git; HMAC (03-02) adds a shared key and gives authenticity — webhooks signed with X-MediNube-Firma, recovery tokens stored as sha256(token); and Argon2id (03-03) turns speed's virtue into a vice, on purpose, to protect the one secret we can't choose to make strong: human passwords. Integrity, authenticity, credentials — all three without encrypting a single byte.
But look at the fine print of the whole arsenal MediNube has built up so far: AES-GCM needs sender and receiver to already share the key; so does HMAC; even the pepper was just another shared secret. Our entire structure rests on an assumption we've been parking since 02-01: that the keys reached both sides by magic. How do MediNube and a brand-new clinic that have never met agree on a key, talking over a network anyone can listen in on? And how does a doctor sign a prescription so that anyone — not just someone sharing a key — can verify it, non-repudiation included? That's the biggest conceptual leap in the course: asymmetric cryptography, where keys come in pairs and half of each pair is published to the four winds. It begins in Module 4 with lesson 04-01: Public-Key Fundamentals and RSA. See you on the other side.
Applied Cryptography Course
Module 1: Cryptography Fundamentals
- What Cryptography Is and What It's For
- Encoding, Obfuscation, and Encryption
- Randomness and Entropy
- Kerckhoffs's Principle and the Golden Rules
Module 2: Symmetric Cryptography
- Symmetric Encryption: AES and ChaCha20
- Modes of Operation
- Authenticated Encryption (AEAD)
- Key Derivation (KDF)
Module 3: Hashes, MACs, and Passwords
Module 4: Asymmetric Cryptography
- Public-Key Fundamentals and RSA
- Elliptic Curve Cryptography
- Digital Signatures
- Key Exchange: Diffie-Hellman
- Hybrid Encryption
