The previous lesson ended with an open question about public keys, but it also left one piece half-finished: when signing a Nimbus receipt it was not the message that was signed, it was its hash. That primitive has been turning up since the first module — in the fingerprint of the backups, in the HMAC on the gateway webhook, in the TOTP, in the credentials table — and so far nobody has explained it. This lesson develops it in full: what a cryptographic hash function is and what properties it has, why MD5 and SHA-1 are broken, why a bare hash authenticates nothing and how HMAC is born from that, and — the heart of the lesson and an explicit debt from module 2 — how Nimbus should store its users' passwords. It is probably the most directly applicable lesson in the whole module: almost any system you build will store passwords, and doing it badly is the difference between an annoying leak and a catastrophe for thousands of people.
Contents
- What a cryptographic hash function is
- Properties and the avalanche effect
- The algorithm family: what to use today and what not to
- Legitimate uses of a hash
- Why a hash does not authenticate: from hash to MAC
- HMAC and the Nimbus payment gateway webhook
- Why SHA-256 is unacceptable for passwords
- Salt and pepper
- Slow derivation functions: Argon2id, scrypt, bcrypt and PBKDF2
- Registering and verifying passwords in Nimbus
- User enumeration and constant-time verification
- What to do if the credentials database leaks
- Non-cryptographic checksums
- What a cryptographic hash function is
A hash function takes an input of any size and produces a fixed-size output, called a digest or fingerprint.
"hello" --SHA-256--> 32 bytes
An 8 GB file --SHA-256--> 32 bytes
An empty string --SHA-256--> 32 bytesThe key is in the adjective cryptographic: anybody can write a function that reduces data to a fixed number — that is an ordinary hash table — and what makes one cryptographic is the guarantees it offers against an adversary deliberately trying to manipulate it. And there is an essential difference from everything seen so far: a hash function has no key. Anybody can compute the hash of anything, which makes it useful for verifying integrity against errors and completely insufficient for authenticating against an attacker, as we will see in section 5.
- Properties and the avalanche effect
| Property | What it means | Why it matters |
|---|---|---|
| Deterministic | The same input always produces the same output | Without this nothing can be verified |
| Fast to compute | Gigabytes per second | It allows large files to be hashed. And it is a problem for passwords (section 7) |
| One-way (preimage resistance) | Given h, it is infeasible to find m such that hash(m) = h |
It allows fingerprints to be stored instead of data: nobody inverts your fingerprint |
| Second preimage resistance | Given m1, infeasible to find m2 ≠ m1 with the same hash |
Nobody replaces your document with another that matches |
| Collision resistance | Infeasible to find any pair m1 ≠ m2 with the same hash |
It is the hardest property to maintain and the first to fall |
| Avalanche effect | Changing one bit of the input changes ~half the bits of the output | It prevents anything about the input being deduced from the output |
The last two deserve attention. Collision resistance is weaker than second preimage resistance because the attacker gets to choose both messages: by the birthday paradox, with an n-bit output collisions are found in about 2^(n/2) operations, not 2^n. That is why SHA-256, with a 256-bit output, offers 128 bits of security against collisions. And why SHA-1, with 160 bits, offered only 80 — and fell.
The avalanche effect is better seen than explained:
import hashlib
def sha256_hex(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
a = "Booking 88421 confirmed for 14/08/2026 at 10:30"
b = "Booking 88421 confirmed for 14/08/2026 at 10:31" # ONE character
ha, hb = sha256_hex(a), sha256_hex(b)
print("A:", ha)
print("B:", hb)
# We count how many BITS differ between the two digests
ba = int(ha, 16)
bb = int(hb, 16)
differing = bin(ba ^ bb).count("1") # XOR: 1 where the bits differ
print(f"Differing bits: {differing} of 256 ({differing / 256:.0%})")Output:
A: 21b9ac9bbf00e71b77a173192d34480d90a788e68fd2ce53a2cc752e784975b5
B: 06db64487908cb3cae27a12f865ef1cacff8e96d5ac4eef13f1f81df194a67c5
Differing bits: 123 of 256 (48%)What you need to understand: a single character of difference — the minute of the booking — and the two digests bear no resemblance to each other. ba ^ bb XORs the two integers, putting a 1 where they differ, and counting the ones gives the Hamming distance. The result always hovers around 50 %, which is what is expected of an output indistinguishable from randomness: if a hash produced a 5 % change, it would reveal information about the input and would be useless. (hexdigest() returns 64 hexadecimal characters, which are the 32 bytes of output; digest() returns the raw bytes.)
- The algorithm family: what to use today and what not to
| Algorithm | Output | Status in 2026 | Use today? |
|---|---|---|---|
| MD5 | 128 bits | Broken. Collisions in seconds on a laptop | Never for security |
| SHA-1 | 160 bits | Broken. Practical collision demonstrated (SHAttered, 2017) | Never for security |
| SHA-256 / SHA-512 | 256 / 512 bits | Solid. SHA-2 family | Yes. The default choice |
| SHA-3 (SHA3-256…) | Variable | Solid, different construction (sponge) | Yes, when design diversity is wanted |
| BLAKE2 / BLAKE3 | Variable | Solid and faster than SHA-2 | Yes, for high-volume integrity |
| CRC32 | 32 bits | It is not cryptographic | Only transmission error detection (section 13) |
Why MD5 and SHA-1 are broken. They are not broken in preimage — nobody inverts a SHA-1 hash — but in collision resistance: two different documents can be constructed with the same digest. MD5 fell in 2004 and today collisions are generated in seconds; it was even demonstrated that two different digital certificates could be constructed with the same hash, which allowed identities to be impersonated. SHA-1 fell publicly in 2017 with the SHAttered attack, which produced two different PDFs with the same digest, and in 2020 chosen-prefix collision attacks brought the cost down to tens of thousands of euros of computation, which led to its definitive withdrawal from certificates and signatures.
Why a collision is so serious. Recall from section 8 of 03-03 that a signature is computed over the hash. If an attacker fabricates two documents with the same digest — one innocuous, one malicious — they get you to sign the innocuous one and that very signature is valid for the malicious one. The signature is correct; it was the hash that betrayed it.
A practical caveat: MD5 and SHA-1 still turn up in legacy code for things that are not security — cache keys, internal deduplication, resource identifiers. In those uses there is no risk, but it is worth documenting it explicitly so that an auditor does not flag it and, above all, so that nobody reuses them later with security in mind.
- Legitimate uses of a hash
| Use | Example in Nimbus |
|---|---|
| Verifying integrity against errors | Checking that the nightly backup transferred completely |
| File fingerprint and deduplication | Identifying without comparing byte by byte; not storing the same attachment twice |
| Index over encrypted data | Searching without decrypting, with a deterministic HMAC (03-07; requires a key) |
| The basis of signatures and MACs | What gets signed is the hash (03-03 and section 6) |
| Identifying versions | Every Git commit is a hash |
The most everyday case for Lucía is verifying a backup:
# 1. When the backup is created, its fingerprint is generated
sha256sum backup-2026-08-02.tar.gz > backup-2026-08-02.tar.gz.sha256
# 2. Months later, after downloading it from the external storage,
# it is verified BEFORE any attempt to restore it
sha256sum -c backup-2026-08-02.tar.gz.sha2569f4c1a7d3e0b8256cf10a94b7d3e6f28c0574ab19e83d2f6410c9b7e5a2d8f31 backup-2026-08-02.tar.gz
backup-2026-08-02.tar.gz: OKsha256sum -c reads the fingerprint file and recomputes the hash of the real archive; if it does not match, it prints FAILED and returns a non-zero exit code, which allows the check to be automated inside the restore script. This detects corruption — a truncated transfer, a disk with bad sectors, a badly decompressed file — and is exactly the "0" of the 3-2-1-1-0 scheme from 02-04, zero verification errors. What it does not detect is an attack, and that is the point that links to the next section.
- Why a hash does not authenticate: from hash to MAC
Go back to the previous example with an attacker's eyes. An intruder with write access to the backup storage replaces backup-2026-08-02.tar.gz with a manipulated version, runs sha256sum over their version and overwrites the .sha256 file with the new result. Lucía's verification will say the checksum matches. And it will match. The flaw is not in SHA-256: it is that a hash has no key, so anybody can recompute it.
The rule: a hash protects against accidents; a MAC or a signature protects against adversaries. If the attacker can modify the data, they can also modify its hash.
You already know the two solutions: the MAC (HMAC), with a shared key, when the two parties know each other and no proof against third parties is needed — which is what this section develops; and the digital signature, with an asymmetric pair, when the verifier could be anybody or non-repudiation is required (03-03).
Why hash(key || message) is not enough
The naive construction consists of concatenating the key in front of the message and hashing. It seems reasonable: without the key you cannot compute it. And it is insecure with the SHA-2 family of functions, because of the length extension attack.
The reason lies in how they work internally: SHA-256 processes the message in blocks and its output is its final internal state. An attacker who knows hash(key || message) and the length of the key can resume the function from that state and compute hash(key || message || padding || appended_data) without knowing the key. They obtain a valid MAC for a message they have extended.
Translated to Nimbus: if the signed URL scheme used that construction, an attacker could start from a legitimate signed URL and add parameters to it — for example, extending the expiry or changing the scope — generating a valid signature without the key.
HMAC exists precisely for that. Its construction uses the key twice, in two nested passes:
The outer pass makes it impossible to reconstruct the internal state, and with it the extension attack. Do not implement it yourself: hmac.new(...) in Python already does.
- HMAC and the Nimbus payment gateway webhook
The payment gateway notifies Nimbus when a payment completes, via an HTTP request to a public endpoint. Without verification, anybody who discovered that URL could send false payment notifications — which, in a bookings SaaS, translates into appointments confirmed without being paid for. The gateway and Nimbus share a secret and sign every notification with HMAC-SHA256.
import hmac, hashlib, time
from fastapi import APIRouter, Request, HTTPException
router = APIRouter()
WEBHOOK_SECRET: bytes = get_secret("gateway/webhook") # manager (03-06)
TOLERANCE_SECONDS = 300 # 5 minutes
def expected_signature(body: bytes, timestamp: str) -> str:
"""Timestamp + RAW body are signed, not the reserialised JSON."""
return hmac.new(WEBHOOK_SECRET,
timestamp.encode("ascii") + b"." + body,
hashlib.sha256).hexdigest()
@router.post("/webhooks/pasarela")
async def receive_webhook(request: Request):
body = await request.body() # 1. RAW body, exactly as it arrived
timestamp = request.headers.get("X-Pasarela-Timestamp", "")
received = request.headers.get("X-Pasarela-Signature", "")
if not timestamp or not received:
raise HTTPException(400, "Signature headers missing")
# 2. FRESHNESS: old notifications are rejected (anti-replay)
try:
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
raise HTTPException(400, "Notification expired")
except ValueError:
raise HTTPException(400, "Invalid timestamp")
# 3. CONSTANT-TIME COMPARISON
if not hmac.compare_digest(expected_signature(body, timestamp), received):
record_event("webhook.invalid_signature", ip=request.client.host)
raise HTTPException(401, "Invalid signature")
# 4. IDEMPOTENCY: the same notification may arrive twice
event = parse(body)
if already_processed(event["id"]):
return {"status": "duplicate_ignored"}
process_payment(event)
return {"status": "ok"}An analysis of the decisions, all of which are deliberate:
- The raw body is signed (
await request.body()), not the reserialised dictionary. If you parse the JSON and serialise it again, any difference in spacing or key order changes the bytes and the signature fails. It is the same canonicalisation problem as in 03-03, and the solution is to sign exactly what travelled over the network. - The timestamp is signed too, joined to the body with a separator. If only the body were signed, an attacker could capture a legitimate notification and replay it tomorrow with the signature intact. That is the freshness property of 03-01, and the five-minute window is what guarantees it; the same reasoning that lies behind the 30-second window of the TOTP in 02-05.
hmac.compare_digestcompares in constant time: it takes the same whether it is the first character or the last that matches. With==, Python would stop at the first difference and an attacker measuring thousands of attempts would reconstruct the signature character by character (03-01). That line, on its own, is the difference between a secure webhook and a vulnerable one. Note: both arguments must be of the same type (here,hexdigest()on both sides).- The failed attempt is logged, with the IP and without dumping the body (02-04): a run of invalid signatures is an alert. And idempotency, which is not cryptography but is indispensable, because gateways retry and processing the same payment twice is a business incident.
This same pattern — HMAC over a message that includes an expiry — is exactly the one behind the 120-second signed URLs for the A-05 bucket that we have been citing since module 1. It is taken apart from the inside in 03-07.
- Why SHA-256 is unacceptable for passwords
We come to the heart of the lesson. Nimbus stores its users' credentials, and those people — certainly — reuse their passwords on other services. Storing them badly does not only compromise Nimbus: it compromises the e-mail, the bank and the digital life of thousands of patients and practitioners.
Let us start with what is never done:
Storing them in the clear is indefensible: any leak, backup or SELECT exposes them. Encrypting them is no good either, because there is a key that decrypts them and whoever steals the database can usually steal the key as well — and besides, you never need to recover a password, only to verify it. And md5(password) adds a broken algorithm on top of all of the above.
The problem with sha256(password) is that SHA-256 is fast. That virtue, which makes it excellent for verifying an 8 GB backup, is exactly the defect that disqualifies it here:
| Attack | Cost with bare SHA-256 |
|---|---|
| GPU brute force | A modern GPU computes on the order of 10 billion SHA-256 per second. A dictionary of the 10 billion most common passwords is exhausted in one second |
| Rainbow tables | Precomputed tables that invert common hashes instantly, publicly available |
| Identical hashes | Without salt, two users with the same password have the same hash: you can see at a glance who shares a password, and breaking one breaks them all |
A calculation that helps to internalise it: an eight-character alphanumeric password has some 218 trillion combinations, which at 10 billion per second are exhausted in less than six hours with a single GPU; with well-calibrated Argon2id, that same search would go from hours to centuries, because each attempt costs hundreds of thousands of times more. From this come the three pieces of the solution: salt (which kills precomputed tables and identical hashes), deliberate slowness (which makes each attempt expensive) and memory cost (which cancels out the GPU advantage).
- Salt and pepper
The salt is a random value, unique per user, that is combined with the password before deriving the hash and is stored alongside it, in the clear.
The four questions that always come up: it is not secret (it is stored next to the hash and nothing happens); it cannot be the same for everybody nor derived from the e-mail address or the ID, because it must be random and unpredictable and e-mail addresses repeat across services; 16 bytes from secrets is the standard size; and it is stored inside the hash string itself, because modern formats already include it. What it achieves exactly: it cancels rainbow tables (a precomputed table only works for one specific salt, so you would have to build one per user), it breaks the equality of hashes (two users with the password Valencia2026 have completely different hashes) and it forces attacks to be made one at a time, because without salt an attacker tries each candidate against all 40,000 users at once.
The pepper is an application-wide secret added on top of the salt, whose crucial difference is where it lives:
| Salt | Pepper | |
|---|---|---|
| Scope and secrecy | Unique per user, not secret | Unique to the application, secret |
| Where it lives | In the database, next to the hash | Outside the database: secrets manager, HSM or process variable |
| What it contributes | Cancels tables and collisions | If only the database is stolen, the hashes are unattackable |
The pepper is a valuable layer of defence in depth — it covers exactly the most frequent scenario, the database dump through a SQL injection — but it has a real cost: rotating it forces every user to change their password, or requires a dual-verification scheme, because it cannot be recomputed without the original passwords. For Nimbus, the recommendation is to implement Argon2id correctly first and consider the pepper afterwards, in the form of an HMAC applied to the password before deriving it, with the key in the secrets manager.
- Slow derivation functions: Argon2id, scrypt, bcrypt and PBKDF2
These functions are called password KDFs (password-based key derivation functions) and their design is deliberately expensive. It is the distinction we announced in 03-02: HKDF derives keys from keys and is fast; these derive from low-entropy secrets and must be slow.
| Algorithm | Memory cost | Starting parameters in 2026 | Recommendation |
|---|---|---|---|
| Argon2id | Yes, configurable | memory 19 MiB, time 2, parallelism 1 (or 46 MiB / 1 / 1) | First choice. Winner of the Password Hashing Competition |
| scrypt | Yes | N = 2^17, r = 8, p = 1 | A good alternative if Argon2 is not available |
| bcrypt | Little (4 KB fixed) | cost 12 | Acceptable. Limited to 72 bytes of input |
| PBKDF2-HMAC-SHA256 | No | 600,000 iterations | Only if a standard requires it (FIPS, for example). The weakest against GPUs |
Always check the current OWASP guidance, because hardware moves on. The key is the memory cost: a GPU has thousands of cores but little memory per core, so an algorithm demanding 19 MB per computation stops those thousands of cores from working in parallel and the advantage of specialised hardware evaporates. PBKDF2 only demands CPU time, which is exactly what GPUs and ASICs make cheap.
How to calibrate them. The criterion is not to memorise numbers, it is to measure on your hardware: set a time budget per verification of between 250 and 500 ms in production (less leaves the attacker too much margin, more degrades the experience and opens a denial of service route); raise the memory first, which is what penalises GPUs, and then the time; measure with realistic concurrency, because 20 simultaneous logins at 46 MiB each are almost 1 GB of RAM at peak, and that is a capacity parameter as well as a security one; and note the values and the date so as to review them every year, knowing that the transparent rehash of the next section makes raising them cost nothing.
- Registering and verifying passwords in Nimbus
The schema of the credentials table. Note what it does not contain:
CREATE TABLE credentials (
user_id BIGINT PRIMARY KEY REFERENCES users(id),
-- Full PHC string: it includes algorithm, parameters, salt and hash.
-- There is no separate "salt" column: it travels inside.
password_hash TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
failed_attempts SMALLINT NOT NULL DEFAULT 0, -- brute force (02-05)
locked_until TIMESTAMPTZ
);
-- The reporting role (01-04) has no business here:
REVOKE ALL ON credentials FROM nimbus_reports;
GRANT SELECT, UPDATE ON credentials TO nimbus_api;An Argon2id PHC string looks like this:
$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHR2YWx1ZQ$3xTk9pQmR0aVdlbGxEb25lSGFzaFZhbHVl
algorithm ver parameters random SALT derived hash
(19456 KiB, 2 passes, 1 thread) (both in Base64)Everything needed to verify is in that string, and that is why no salt column or parameters column is needed: when you change the parameters, the old hashes will still verify with theirs. The code, using the argon2-cffi library, which is the reference implementation in Python:
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
# Parameters centralised in ONE single place (section 9)
ph = PasswordHasher(memory_cost=19456, time_cost=2, parallelism=1)
# "Decoy" hash: used when the user does NOT exist, so that the response
# takes the same time. It is computed once when the process starts.
DECOY_HASH = ph.hash("password-that-nobody-will-ever-use")
def register(user_id: int, password: str, repo) -> None:
if len(password) < 12: # policy (02-05), BEFORE deriving
raise ValueError("The password must be at least 12 characters long")
repo.save_hash(user_id, ph.hash(password)) # the library generates the salt
def verify(email: str, password: str, repo) -> bool:
user = repo.find_by_email(email)
# If the user does not exist we verify against the decoy: identical cost,
# the attacker cannot tell the difference (section 11).
stored = user.password_hash if user else DECOY_HASH
try:
ph.verify(stored, password)
except (VerifyMismatchError, VerificationError, InvalidHashError):
return False
if user is None: # it was the decoy: always fails
return False
# TRANSPARENT REHASH: if the parameters have changed, recompute now
# that we hold the plaintext password (the only possible moment).
if ph.check_needs_rehash(stored):
repo.save_hash(user.id, ph.hash(password))
return TrueAn explanation of the non-obvious decisions:
ph.hash(password)generates the salt internally from a secure source and returns the complete PHC string. There is no decision you can get wrong.ph.verifyraises an exception instead of returningFalse. The same design criterion asverifyin Ed25519 (03-03): it forces you to handle the failure.VerifyMismatchErroris an incorrect password;InvalidHashErrormeans the stored string is corrupt or in another format, and deserves an alert.- The decoy hash is the piece from section 11. Without it, the response for a non-existent user would be immediate and for an existing one would take 300 ms: that difference is user enumeration.
check_needs_rehashcompares the parameters of the stored string with the current ones. If you have raised the memory from 19 to 46 MiB, it returnsTrueand the hash is updated at the only instant when you have the plaintext password available. With this mechanism, hardening the parameters requires no migration and inconveniences nobody: the user base updates itself as people log in.- The length policy is checked before deriving, so as not to spend 300 ms of CPU on an already invalid input; and a reasonable maximum must also be set (128 characters), because without it somebody could send 10 MB of password and cause a denial of service. And never log the password, not even in a temporary
logger.debug: it is the error from 02-04 and it survives in the logs far longer than anybody expects.
- User enumeration and constant-time verification
A system enumerates users when it allows you to tell whether an account exists. It seems minor and it is not: it is the step before password spraying and the targeted phishing of 02-03. Knowing that [email protected] exists turns a generic attack into a personalised one.
The four leaks through which that information escapes:
| Leak | Symptom | Fix |
|---|---|---|
| Different message | "User not found" versus "Incorrect password" | A single message: "Incorrect credentials" |
| Different timing | 5 ms if it does not exist, 300 ms if it does | Decoy hash (section 10) |
| Response code or structure | 404 versus 401, or different fields | An identical response |
| Registration and recovery | "That e-mail address is already registered" | "If the e-mail address is available, you will receive a message" |
The timing one is the most overlooked because it cannot be seen by reading the code: it has to be measured, timing a hundred attempts with an existing e-mail address and a hundred with an invented one; if the averages differ consistently, there is a leak.
And do not confuse two things that look alike: constant time in the comparison (hmac.compare_digest) prevents a secret being reconstructed character by character, while constant time in the overall response (decoy hash) prevents anyone telling whether an account exists. They are different defences against different attacks and both are needed. An honest caveat: perfect equality of timings is unattainable in a real system with caches and a network; the goal is for the difference to sit far below the measurement noise, not for it to be zero.
- What to do if the credentials database leaks
Scenario: Lucía discovers that a dump of the credentials table has left the infrastructure. With well-calibrated Argon2id, the passwords are not immediately usable, but that does not mean there is nothing to do.
The first few hours: (1) contain and preserve — cut off the access used, keep evidence and logs, following the incident response plan of 04-05; (2) determine the exact scope: how many users, which other tables, what time window; (3) invalidate every active session and token, because a valid session does not need a password; (4) force a reset through a flow that does not depend on the old password.
The following days: (5) rotate the pepper, if there is one, and any secret that was in the same database; (6) notify within the deadline; (7) harden the Argon2id parameters and force the rehash; (8) carry out the root cause analysis with the method from 02-06: you do not look for a culprit, you look for why the control was not there.
Professional validation note. The GDPR requires the supervisory authority to be notified within a maximum of 72 hours of becoming aware of the breach where there is a risk to the rights of the individuals affected, and requires those individuals to be informed if the risk is high. Since Nimbus's data includes information that indirectly reveals health status, the analysis must be carried out with the Data Protection Officer and with legal advice. The regulatory detail is covered in 06-03.
What NOT to do: publicly minimise it ("the hashes are encrypted, there is no risk"), delay the notification while waiting for the complete report, or assume that users do not reuse those passwords on other services. They do. That is the real reason Argon2id matters.
- Non-cryptographic checksums
CRC32, Adler-32 and the check digits of an IBAN or a DNI (the Spanish national ID number) are checksums, not cryptographic hashes. Their purpose is to detect accidental transmission or typing errors — and for that they are excellent — but they are designed against noise, not against an adversary: CRC32 produces a 32-bit output and building a collision at will is trivial, whereas in SHA-256, with 256 bits, it is infeasible. Correct use of CRC32: Ethernet, ZIP, PNG, corruption detection. Correct use of SHA-256: integrity, signatures and fingerprints.
The mistake to avoid is using CRC32 where there is an adversary: as a "unique" identifier for an attachment, as a check that a downloaded file has not been tampered with, or as part of an authentication scheme. An attacker builds a CRC32 collision in milliseconds and in whatever direction they like. The complete rule, to close: against accidental errors, CRC32 or SHA-256; against an adversary who modifies data, HMAC or a signature; against an adversary who steals the password database, Argon2id with a unique salt.
Common Mistakes and Tips
On hashes and MACs
- Using MD5 or SHA-1 for security. They are broken in collision, and a collision breaks any signature built on top of them. The same goes for CRC32 where there is an adversary.
- Believing that a hash published alongside the file protects against an attacker. It only protects against errors: whoever can change the file can change the hash.
- Building a MAC as
hash(key || message). Vulnerable to length extension. Use HMAC. - Comparing signatures with
==. A timing side channel. Alwayshmac.compare_digest. - Signing the reserialised JSON instead of the raw body. It produces intermittent failures that look like attacks.
- Not including a timestamp or checking freshness. Without that, a legitimate message captured today is replayed a month later.
On passwords
sha256(password). Ten billion attempts per second with a GPU.- A fixed, shared salt, or one derived from the e-mail address. It must be random and unique per user; let the library generate it.
- Implementing Argon2 by hand or copying a snippet from a forum. Use
argon2-cffior theArgon2idfromcryptography. - Different messages for "does not exist" and "incorrect password", or forgetting the decoy hash. The two routes to user enumeration; the second cannot be seen by reading the code.
- Not setting a maximum length. An open route to denial of service.
- Setting the parameters and never looking at them again for five years. Use
check_needs_rehashand review them annually.
Tips
- Centralise password hashing in one single function in the code base. No other part of the system should call
ph.hashdirectly. - Add a check in CI (02-04) that rejects
hashlib.md5,hashlib.sha1andsha256(applied to anything calledpassword, and record metrics for the average verification time: if it goes up, a parameter is badly calibrated; if it drops suddenly, somebody has lowered the cost. - Remember the hierarchy: a hash for errors, a MAC or a signature for adversaries, a slow KDF for passwords. Choosing correctly between the three solves most of the problems in this lesson.
Exercises
Exercise 1 — Auditing a webhook verification
Nimbus also receives notifications from the transactional e-mail provider. This is the code:
import hashlib
SECRET = "nimbus-email-2026"
@app.post("/webhooks/email")
async def receive(request: Request):
data = await request.json() # (1)
signature = request.headers.get("X-Signature", "")
computed = hashlib.sha256(
(SECRET + json.dumps(data)).encode() # (2)
).hexdigest()
if computed != signature: # (3)
raise HTTPException(401)
process(data) # (4)
return {"ok": True}You are asked to: (a) identify the four marked problems and the concrete attack each one enables; (b) explain why problem (2) is a cryptographic failure and not merely a stylistic one; (c) rewrite the endpoint correctly; (d) state what must be logged and what must not be logged in this flow.
Exercise 2 — Migrating password storage
Nimbus has 41,000 credentials stored as sha256(password) without salt. Marta wants to migrate to Argon2id without forcing everybody to change their password at once.
You are asked to: (a) explain why a SHA-256 hash cannot be converted directly into an Argon2id hash; (b) design a progressive migration strategy, stating which column you would add, what happens at each user's next login and what happens to somebody who never logs in; (c) write the pseudocode of the verification during the migration; (d) decide what to do with the accounts still on SHA-256 after six months and justify it; (e) state whether this situation should be handled as a security incident and why.
Exercise 3 — Calibrating and defending the parameters
Iván proposes PasswordHasher(memory_cost=8, time_cost=1, parallelism=1) because "that way the login is instantaneous".
You are asked to: (a) explain what each parameter means and what that memory value implies; (b) qualitatively estimate how much cheaper the attacker's work becomes compared with the recommended parameters; (c) describe the procedure you would follow to calibrate the real parameters on the Nimbus server, including what you would measure and at what concurrency; (d) formulate the answer you would give Iván and Marta, covering the trade-off between user experience, server capacity and the risk of denial of service.
Solutions
Exercise 1
(a) The four problems:
| # | Problem | Attack |
|---|---|---|
| 1 | The JSON is parsed and then reserialised in order to sign it | Any difference in spacing, key order or encoding produces different bytes: the legitimate signature fails and false rejections are generated. And if it is "fixed" by relaxing the comparison, the door opens to tampering |
| 2 | sha256(SECRET + message) |
Length extension: an attacker who captures a signed message can append data to the end and compute a valid signature without knowing the secret. On top of that, the secret is in the code |
| 3 | Comparison with != |
Timing side channel: the signature is reconstructed character by character by measuring times |
| 4 | No timestamp, no freshness and no idempotency | Replay: capture a legitimate notification and resend it tomorrow, as many times as you like |
(b) Because it is not a matter of taste but of the internal construction of SHA-2: its output is its internal state, so resuming it allows the computation to be continued. HMAC is not "the same thing with more steps": its second pass is precisely what prevents that attack.
(c) Correct version. It is the pattern from section 6, applied to this provider:
import hmac, hashlib, time, json
SECRET = get_secret("email/webhook") # bytes, from the manager (03-06)
@app.post("/webhooks/email")
async def receive(request: Request):
body = await request.body() # RAW bytes
timestamp = request.headers.get("X-Timestamp", "")
signature = request.headers.get("X-Signature", "")
if not timestamp or not signature or abs(time.time() - int(timestamp)) > 300:
raise HTTPException(400, "Headers missing or expired")
message = timestamp.encode() + b"." + body
expected = hmac.new(SECRET, message, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
record_event("webhook.email.invalid_signature", ip=request.client.host)
raise HTTPException(401, "Invalid signature")
event = json.loads(body)
if already_processed(event["id"]):
return {"ok": True, "status": "duplicate"}
process(event)
return {"ok": True}(d) Logging. Yes: event identifier, type, timestamp, source IP, outcome (accepted/rejected) and the reason for rejection. No: the complete body (it contains e-mail addresses and possibly message content), the complete received signature, the secret, or any authentication header. It is the rule from 02-04: log enough to detect and to investigate, without turning the log into a second copy of the personal data.
Exercise 2
(a) Because a hash is one-way: from sha256(password) the password cannot be recovered, and Argon2id needs the plaintext password to derive its own hash. The only possible conversion would be argon2id(sha256(password)) — hashing the hash — which works but leaves the system tied indefinitely to an unsalted SHA-256 layer, complicates the logic and is not the best option if a clean migration is possible.
(b) Progressive strategy. Detect the format by the $argon2id$ prefix of the string itself (or add an algorithm column). At each user's next successful login the plaintext password is available: it is verified with the old scheme and, if correct, recomputed with Argon2id and replaced, without the user noticing anything. Anybody who never logs in stays on the old hash, and that is why step (d) is needed.
(c) Pseudocode:
def verify(email, password, repo):
u = repo.find_by_email(email)
stored = u.password_hash if u else DECOY_HASH
if stored.startswith("$argon2id$"):
ok = argon2_verify(stored, password)
if ok and u and ph.check_needs_rehash(stored):
repo.save_hash(u.id, ph.hash(password))
else:
# Legacy path: constant-time comparison of the old hash
ok = hmac.compare_digest(
hashlib.sha256(password.encode()).hexdigest(), stored
)
if ok and u:
repo.save_hash(u.id, ph.hash(password)) # MIGRATION
return bool(ok and u)(d) After six months. The accounts still on SHA-256 are, almost by definition, inactive accounts: a double risk (a weak hash and an orphaned account from 02-05). The right thing to do is to force a password reset on those accounts — invalidating them until the user completes the flow — and, for those with more than a year of inactivity, to evaluate their deactivation in line with the life-cycle policy. Keeping unsalted SHA-256 hashes indefinitely is accepting a known risk without justification.
(e) Is it an incident? It is not a breach — nothing has left — but it is a serious security finding that must be recorded in the risk register, with an owner and a deadline, and treated as such (module 4). If there were also any indication that the table had ever been copied, it would become an incident and section 12 would apply, including the assessment of notification with legal advice.
Exercise 3
(a) memory_cost is in KiB: 8 KiB, that is, eight kilobytes, against the 19,456 KiB (19 MiB) recommended; some 2,400 times less. time_cost=1 is a single pass, and parallelism=1 a single thread. With 8 KiB, Argon2id loses its characteristic defence entirely: it fits comfortably in the cache of each GPU core and becomes comparable to a fast hash.
(b) The cost per attempt drops by at least three orders of magnitude, and the advantage of parallel hardware is fully restored. A search that with correct parameters would cost centuries comes back down to the order of days or weeks for a middling password. Qualitatively: Iván's configuration has the name of Argon2id and the security of a fast salted hash.
(c) Calibration procedure. Measure on the production hardware, not on the development laptop; start from memory 19 MiB, time 2, parallelism 1 and time ph.hash() a hundred times; adjust until the median sits between 250 and 500 ms, raising the memory first; repeat the measurement at the maximum expected concurrency of simultaneous logins, watching RAM and the latency of the rest of the API; and document values, date, hardware and the 95th percentile, with an annual review scheduled.
(d) Answer to Iván and Marta. Login does not need to be instantaneous: 300 ms is imperceptible to a person who has just typed their password, and it happens once per session, not on every request — the rest of the time authentication is handled by the token of 02-05. That quarter of a second is exactly what multiplies the attacker's cost by millions if the table ever leaks. The real trade-off is not against user experience, but against server capacity: the RAM has to be sized for the peak of simultaneous logins and the measure must be combined with rate limiting and progressive lockout (02-05) so that nobody can use the cost of the hash itself as a denial of service vector.
Conclusion
You have covered the missing primitive and settled the debt module 2 left outstanding. You know what a cryptographic hash function is — fixed output, deterministic, one-way — and its three resistances, with collision resistance as the most fragile and the first to fall; you have seen the avalanche effect measured in bits and why 50 % is the right figure. You know the real state of the family: MD5 and SHA-1 broken in collision, with SHAttered as the reference, and why a collision destroys any signature built on top; SHA-256/512, SHA-3 and BLAKE2 as the current options.
And you have learned the boundary that separates two worlds: a hash protects against accidents; a MAC or a signature protects against adversaries, because whoever can alter the file can also recompute its sha256sum. Hence HMAC, with the exact reason why hash(key || message) is insecure — the length extension attack — applied to the payment gateway webhook with its four deliberate decisions: sign the raw body, include the timestamp to give freshness, compare with hmac.compare_digest and be idempotent. And you close with the distinction between checksums such as CRC32 and cryptographic hashes.
The heart of the lesson was password storage, and there is the complete answer Nimbus needed: sha256(password) is unacceptable because it is fast — ten billion attempts per second with a single GPU — a unique salt per user cancels precomputed tables and forces attacks one at a time, the pepper lives outside the database and covers the dump through SQL injection, and the solution is a slow derivation function with a memory cost: Argon2id as first choice, with 19 MiB, two passes and one thread as the starting point, calibrated to 250–500 ms on the real hardware. You have implemented it in full: a PHC string with the salt inside, the credentials table, a decoy hash so that the response takes the same time whether or not the user exists, and a transparent rehash with check_needs_rehash to harden parameters without migrations or inconvenience, plus the plan for what to do if the table leaks — contain, invalidate sessions, force a reset and notify within the deadline with legal validation.
You now have the module's four primitives: symmetric encryption, asymmetric encryption, hash and MAC, and digital signature. In Cryptographic Protocols (03-05) you will see something uncomfortable and necessary: that combining them all correctly does not guarantee a secure system, because the assembly has failures of its own. It is the lesson where the TLS you have been taking for granted for eight lessons is finally explained in depth — its handshake step by step, its versions, its suites, how to check it with openssl s_client and how to configure it properly — as well as SSH, VPN, mTLS and the classic attacks against protocols: downgrade, stripping, MITM and replay.
Fundamentals of Information Security Course
Module 1: Introduction to Information Security
- Basic Concepts of Information Security
- Types of Threats and Vulnerabilities
- Principles of Information Security
- Assets, Attack Surface and Threat Actors
Module 2: Cybersecurity
- Definition and Scope of Cybersecurity
- Types of Cyber Attacks
- Social Engineering and Phishing
- Protection Measures in Cybersecurity
- Identity, Authentication and Access Control
- Cybersecurity Incident Case Studies
Module 3: Cryptography
- Introduction to Cryptography
- Symmetric Cryptography
- Asymmetric Cryptography
- Hash Functions, HMAC and Password Storage
- Cryptographic Protocols
- Key Management, Certificates and PKI
- Applications of Cryptography
Module 4: Risk Management and Protection Measures
- Risk Assessment
- Security Policies
- Security Controls
- Third-Party and Supply Chain Risk
- Incident Response Plan
- Disaster Recovery and Business Continuity
Module 5: Security Tools and Techniques
- Vulnerability Analysis Tools
- Monitoring and Detection Techniques
- Penetration Testing
- Network Security
- Application Security
- System Hardening and Endpoint Security
- Cloud and Container Security
Module 6: Best Practices and Regulations
- Best Practices in Information Security
- Security Regulations and Standards
- Personal Data Protection and GDPR in Practice
- Compliance and Auditing
- Training and Awareness
- Ethics, Legal Aspects and Responsible Disclosure
