Closing out Module 2 left three questions hanging: how do you check that a file hasn't changed without decrypting it, how do you authenticate a message between two parties who share a key, and how does MediNube store its portal users' passwords? All three answers are built on a single primitive we've been smuggling in since Module 2 (inside HMAC-SHA256 in PBKDF2, inside HKDF-SHA256): the cryptographic hash function. This lesson finally brings it into the spotlight: what properties it has to satisfy, why collision resistance is "worth half the bits" (the debt Module 1 left unpaid), which algorithms are alive and which are broken, and what a plain hash is good for — and not good for. It's the foundation on which 03-02 builds HMAC and 03-03 builds password storage: get this piece right and the other two fall into place on their own.

Contents

  1. What a cryptographic hash function is
  2. The three resistances: preimage, second preimage, and collisions
  3. The birthday paradox: why collisions are worth half the bits
  4. The algorithm zoo: SHA-2, SHA-3, BLAKE2... and the dead ones (MD5, SHA-1)
  5. Legitimate uses of a plain hash
  6. Practical case: fingerprinting exported medical records in MediNube
  7. Streaming hashing of large files
  8. What a plain hash is NOT

What a cryptographic hash function is

A cryptographic hash function takes an input of any length (one byte, a medical record, an entire disk) and produces a fixed-length output called a hash, digest, or fingerprint. For SHA-256, that output is always 32 bytes (256 bits), no matter how much or how little goes in.

import hashlib

medical_record = b"Patient: Ana Perez. Allergies: penicillin."
fingerprint = hashlib.sha256(medical_record).hexdigest()
print(fingerprint)
# a027f8994ab69cee3e3634c87038f76a2eb3d6a9337cd6b3588df9445cb264e6

Breaking down the code:

  • hashlib is Python's standard-library module for hash functions. No installation required.
  • hashlib.sha256(...) creates a hash object and processes the bytes you pass it. Notice the input is bytes (b"..."), not text: just like encryption in Module 2, if you have a str you must first encode it with .encode("utf-8").
  • .hexdigest() returns the fingerprint as a hex string (64 characters = 32 bytes). Its sibling .digest() returns the raw 32 bytes, which is what you'll use when the hash feeds into another operation.

Properties it must satisfy to be called cryptographic:

  • Deterministic. The same input always produces the same output. Today, tomorrow, on another machine. Without this it couldn't verify anything.
  • Fixed-length output. 256 bits for SHA-256, 512 for SHA-512. The immediate consequence: since there are infinitely many possible inputs and only 2^256 outputs, collisions exist by definition (two distinct inputs with the same hash). Security doesn't consist of collisions not existing, but of it being computationally infeasible to find them.
  • Fast to compute. Hundreds of MB/s on an ordinary laptop. This is a virtue for verifying files... and, as we'll see in 03-03, a disaster for passwords.
  • Avalanche effect. Changing a single input bit changes, on average, half the output bits, unpredictably:
import hashlib

a = hashlib.sha256(b"Allergies: penicillin").hexdigest()
b = hashlib.sha256(b"Allergies: Penicillin").hexdigest()  # one capital letter
print(a)  # 6b5d338f75d462f378f5b064686981cac67e177ec35e5934591b4aea7572ddc1
print(b)  # e537c9e7216cdcb81616e7250cf23845dd87a3a1ae07c7b9005aea310f41c591

The two fingerprints look nothing alike. This is what makes a hash useful as a change detector: there's no way to make a "small" modification that goes unnoticed, nor to deduce from the fingerprint how much or where the input changed.

A useful mental model: an ideal cryptographic hash function behaves like a random oracle — a machine that, for every new input, picks a perfectly random output and remembers it forever. All the cryptography built on hashes (HMAC, signatures, KDFs) assumes the real hash resembles that ideal machine closely enough.

The three resistances: preimage, second preimage, and collisions

That collisions exist but can't be found is formalized in three properties, ordered from strongest to easiest to break:

Property The attacker has... ...and wants to find Example attack on MediNube
Preimage resistance A hash h Any m such that hash(m) = h Reconstructing a medical record's contents from its published fingerprint
Second preimage resistance A message m1 Another m2 ≠ m1 with hash(m2) = hash(m1) Replacing one specific archived medical record with a fake one that has the same fingerprint
Collision resistance Nothing (chooses both) Any pair m1 ≠ m2 with the same hash Preparing two versions of an informed consent form — one harmless and one abusive — with the same fingerprint, and filing one while passing it off as the other

Details worth internalizing:

  • In a preimage attack, the attacker works "backwards": from the hash to the input. For SHA-256 that costs on the order of 2^256 attempts — infeasible forever, for practical purposes. Caveat: this assumes the input is unpredictable. If the input has low entropy (a national ID number, a password), the attacker doesn't invert the hash: they try every possible input and compare. That nuance will be the heart of 03-03.
  • In a second preimage attack, the target message is fixed in advance; the attacker can't choose it. Also costs ~2^256.
  • In a collision attack, the attacker has maximum freedom: any pair will do. And that freedom has a price for the defender, quantified by the birthday paradox.

The birthday paradox: why collisions are worth half the bits

Back in Module 1 we said "128 bits is the minimum, 256 the standard" (golden rule 5) and promised to explain why, for hashes, the math comes out differently. Here it is.

The paradox: how many people need to be in a room for it to be likely (>50%) that two of them share a birthday? Intuition says ~183 (half of 365). The real answer is 23. The reason: we're not looking for someone who shares a birthday with one specific day (that would indeed take ~183 people), but for any matching pair — and with 23 people there are already 253 possible pairs, plenty of chances for a match.

Translated to hashes: with n-bit outputs there are 2^n possible fingerprints, but an attacker generating random messages expects to find some collision after ~2^(n/2) attempts, not 2^n. That's exactly the difference between the rows in the table above:

  • Attacking SHA-256's preimage or second preimage resistance costs ~2^256: the target is fixed.
  • Attacking SHA-256's collision resistance costs ~2^128: the attacker accumulates fingerprints and waits for two to clash.

Practical consequences:

  • SHA-256 offers 128 bits of collision resistance — exactly the minimum acceptable under golden rule 5. That's why it's today's default standard, and why very long-lived systems consider SHA-384 or SHA-512 (192 and 256 bits of collision resistance).
  • A 128-bit hash (like MD5, even if it weren't broken) would only offer 2^64 of collision resistance: within reach of a well-funded attacker for years now. No 128-bit-output hash is acceptable where collisions matter.
  • Does that 2^(n/2) ring a bell? It's the same phenomenon that limited nonces in 02-03: with AES-GCM's 96-bit nonces we expected a repeat around 2^48 messages. The birthday paradox again — it's a recurring constant in cryptography.

The algorithm zoo: SHA-2, SHA-3, BLAKE2... and the dead ones

The SHA-2 family: the workhorse

SHA-2 is a family designed by the NSA and standardized by NIST in 2001: SHA-224, SHA-256, SHA-384, SHA-512 (the number is the output size in bits). Internally it works with the Merkle–Damgård construction: it chops the message into blocks and "compresses" them into an internal state, chained like a meat grinder. Two practical notes:

  • SHA-256 is the ecosystem's default choice: TLS, Bitcoin, signatures, HMAC... It's what MediNube uses in HKDF and what we'll use in 03-02.
  • SHA-512 is, curiously, faster than SHA-256 on 64-bit CPUs (it works with 64-bit words), as well as more resistant. SHA-384 is SHA-512 truncated — and that truncation protects it from a structural flaw of Merkle–Damgård called length extension, which we'll see in 03-02, because it's the historical reason HMAC exists in the form it does.

SHA-3: the spare tire for a different technology

When SHA-1 started to wobble (we're getting there), NIST ran a public competition — in the style of the one that produced AES — to have a replacement ready in case SHA-2 fell too. The winner (2015) was Keccak, standardized as SHA-3. What matters to you is the landscape, not the internals:

  • SHA-3 uses a completely different internal construction, the sponge: the message is "absorbed" into a large internal state and the output is then "squeezed" out of it. Not being Merkle–Damgård, it's immune by design to length extension.
  • It's not an urgent replacement: SHA-2 is still healthy. SHA-3 is crypto-agility's (golden rule 8) plan B — it exists so that, if a crack ever appears in SHA-2, the world has somewhere to migrate to. It's in hashlib as hashlib.sha3_256().

BLAKE2: the modern speedster

BLAKE2 descends from a SHA-3 competition finalist, and its pitch is simple: security comparable to SHA-3, but faster than SHA-2 in software. It's in hashlib (blake2b for 64-bit, blake2s for 32-bit) and has built-in extras like configurable output size and a keyed mode. It's an excellent choice for high-volume internal checksums; for anything interoperable, SHA-256 remains the lingua franca.

import hashlib

data = b"full backup of Clinica Sol"
print(hashlib.sha256(data).hexdigest())    # 32-byte output
print(hashlib.sha3_256(data).hexdigest())  # 32 bytes, sponge construction
print(hashlib.blake2b(data, digest_size=32).hexdigest())  # custom size

The dead ones: MD5 and SHA-1

This is where collision theory becomes real history:

  • MD5 (1992, 128-bit output): broken in practice since 2004 — generating collisions takes seconds on a laptop. It wasn't academic: the state-sponsored malware Flame (2012) used an MD5 collision to forge a certificate that appeared signed by Microsoft, and used it to pose as a legitimate Windows update.
  • SHA-1 (1995, 160-bit output): the SHAttered attack (Google/CWI, 2017) published two distinct PDFs with the same SHA-1. It cost ~2^63 operations — huge but affordable. Since 2020 the attack is chosen-prefix (much more flexible), and its cost runs to the tens of thousands of dollars. Browsers have rejected SHA-1 certificates since 2017.

Notice the pattern: in both cases collision resistance (the "cheap" property, 2^(n/2)) fell first; MD5's and SHA-1's preimage resistance is still unbroken. That's why you'll see old systems using MD5 "just as a checksum against accidental corruption" — it technically still detects bits flipped by a failing disk, but it's a terrible idea to keep it around: it invites mixing up use cases and gives an auditor (rightly) something to flag.

Algorithm Output Theoretical collision resistance Status (2026) Verdict
MD5 128 bits 2^64 Broken (collisions in seconds; Flame) Never use
SHA-1 160 bits 2^80 Broken (SHAttered 2017; chosen-prefix 2020) Don't use; migrate legacy usage
SHA-256 256 bits 2^128 Healthy Default standard
SHA-512 / SHA-384 512/384 bits 2^256 / 2^192 Healthy Long-lived; fast on 64-bit CPUs
SHA-3-256 256 bits 2^128 Healthy Plan B; immune to length extension
BLAKE2b 1–64 bytes depends on output Healthy Very fast; great for internal use

Legitimate uses of a plain hash

A hash by itself — no key, no salt, nothing else — solves one specific family of problems: giving a short, reliable fingerprint of some content. Canonical uses:

  • Integrity of downloads and files (checksums). The vendor publishes the SHA-256 of an installer; you download it, compute the hash, and compare. If they match, the file arrived intact. (Important nuance in the final section: this only protects you if you got the fingerprint through a trustworthy channel.)
  • Deduplication. If two files have the same SHA-256, they're the same file: a backup system can store a single copy. Collision resistance is exactly what makes this "if the hash matches, the content matches" legitimate.
  • Content identifiers. git identifies every commit, file, and tree by its hash (historically SHA-1, migrating to SHA-256 precisely because of SHAttered). The address is the content's fingerprint: if the content changes, the address changes. Docker does the same with image layers (sha256:...).
  • Document fingerprinting to detect changes — our practical case.

Practical case: fingerprinting exported medical records in MediNube

Context: the medinube.crypto module already exports encrypted backups (02-04). But there's another flow: when a patient like Ana Pérez exercises their right to data portability (GDPR), MediNube generates a plaintext export (a ZIP of PDFs) that the clinic hands over to the patient. Months later, Ana comes back with "her" export and there's a question to answer: is this exactly the file we generated, or has someone modified it? — usual reminder: fictional data; a real flow involving health data requires a security and compliance (GDPR) review before deployment.

The solution: when generating the export, compute its SHA-256 and store the fingerprint in MediNube's database, alongside the export record.

# medinube/integrity.py
import hashlib

def export_fingerprint(zip_data: bytes) -> str:
    """Computes an export's SHA-256 fingerprint, in hex."""
    return hashlib.sha256(zip_data).hexdigest()

def verify_export(zip_data: bytes, stored_fingerprint: str) -> bool:
    """Checks whether an export matches the registered fingerprint."""
    return hashlib.sha256(zip_data).hexdigest() == stored_fingerprint

# When generating the export for ana.perez:
export = b"...ZIP contents..."          # fictional
record = {"patient": "ana.perez", "sha256": export_fingerprint(export)}

# Months later, when Ana submits a file:
submitted_file = b"...ZIP contents..."
print(verify_export(submitted_file, record["sha256"]))  # True

Why == here and not secrets.compare_digest, given how much we insisted on golden rule 6? Because the fingerprint isn't a secret: an attacker trying to slip in a fake export already knows which hash they need to match (or doesn't care); there's no information a timing side channel could leak. Constant-time comparison is for secrets (tokens, MACs — mandatory again in 03-02). Knowing when each applies is a sign you understand the rule, not just recite it.

And here's the uncomfortable question that sets up the next lesson: this works because the fingerprint lives in our database, out of the attacker's reach. What if we had to send the fingerprint and the file together over the same channel? Hold that question for two more sections.

Streaming hashing of large files

hashlib.sha256(data) requires having every byte in memory. An export of medical images can run into gigabytes; loading it all at once is infeasible. That's why hash objects have update(): you can feed them content in chunks, and the result is identical.

import hashlib

def file_fingerprint(path: str, block_size: int = 1024 * 1024) -> str:
    """SHA-256 of a file read in 1 MiB blocks."""
    h = hashlib.sha256()              # 1. empty initial state
    with open(path, "rb") as f:       # 2. open in binary mode
        while True:
            chunk = f.read(block_size)  # 3. read up to 1 MiB
            if not chunk:              # 4. b"" => end of file
                break
            h.update(chunk)            # 5. "absorb" the chunk
    return h.hexdigest()              # 6. final fingerprint

Key points of the code:

  • hashlib.sha256() with no arguments creates the initial state; each h.update(chunk) advances it. It's the Merkle–Damgård grinder in action: the internal state summarizes everything seen so far using constant memory (a few dozen bytes), no matter whether the file weighs 10 GB.
  • update(a); update(b) is exactly equivalent to update(a + b): the fingerprint depends only on the total concatenation, not on how you chunk it.
  • "rb" is essential: in text mode Python would mangle line endings and the fingerprint wouldn't match across systems.
  • Python 3.11+ has the shortcut hashlib.file_digest(f, "sha256"), which does this loop for you.

What a plain hash is NOT

Let's close with the part that prevents the most incidents. A plain hash gives you integrity against errors, not against enemies, and it doesn't protect low-entropy secrets. Two specific limits:

1. A hash doesn't authenticate. Anyone can compute the SHA-256 of anything: the function is public (Kerckhoffs) and requires no key. If MediNube sent Clínica Sol a message along with its hash, an attacker in the middle could replace the message and recompute the hash of the fake message; the clinic's check would come out fine. The hash says "these bytes are these bytes," never "these bytes came from MediNube." To authenticate you need to mix a secret key into the calculation — that's a MAC, and it's exactly lesson 03-02.

2. A hash isn't for passwords. Storing sha256(password) seems reasonable ("it's one-way, right?") and it's a classic mistake. Preimage resistance is unbreakable when the input is unpredictable, but a human password isn't: the attacker tries entire dictionaries at hash speed — and the hash is designed to be fast, billions of attempts per second on GPUs. The virtue turns into a vulnerability. Passwords require deliberately slow functions (you met them as KDFs in 02-04, and in 03-03 we'll use them as verifiers with Argon2id).

Need Plain hash? Correct tool
Detect corruption/tampering of a file (fingerprint in a trusted location) Yes SHA-256
Deduplicate, identify content Yes SHA-256 / BLAKE2
Authenticate a message between two parties No MAC/HMAC (03-02)
Store user passwords No Argon2id (03-03)
Prove authorship to third parties (non-repudiation) No Digital signature (04-03)

Common Mistakes and Tips

  • Using MD5 or SHA-1 in new code "because it's just a checksum." They're broken on collisions and their presence taints audits. SHA-256 is just as easy to write and needs no justification.
  • Confusing "the hash matches" with "the file is authentic." A match only proves integrity relative to the fingerprint you have; if the fingerprint traveled over the same channel as the file, it proves nothing. Always ask: where did the fingerprint come from, and who could have touched it?
  • Hashing a str without fixing the encoding. hashlib requires bytes; always use an explicit .encode("utf-8"). And if you hash structures (JSON), serialize them canonically (sorted keys, no variable whitespace) or the same information will produce different fingerprints.
  • Comparing everything with compare_digest "just in case," or, the other way around, never using it. The criterion: is the compared value a secret, or does it let you guess one? A file's public fingerprint → == is fine; a token or MAC → constant time is mandatory (03-02).
  • Truncating hashes carelessly (keeping just the first 8 hex characters "for brevity"). Every bit you drop reduces resistance; remember collisions are already only worth n/2 bits. If you need short IDs, own and document the resulting collision risk.
  • Tip: internalize golden rule 7's question (goal before tool): "who is the adversary?" A disk that corrupts bits? A hash. An active attacker? At minimum, a MAC.

Exercises

Exercise 1. Without running it, predict what this code prints and explain which hash properties justify it:

import hashlib
h1 = hashlib.sha256(b"medinube").hexdigest()
h2 = hashlib.sha256(b"medinube").hexdigest()
h3 = hashlib.sha256(b"medinubE").hexdigest()
print(h1 == h2, h1[:8] == h3[:8], len(h1) == len(h3))

Exercise 2. A colleague proposes identifying MediNube medical records' attached documents by their MD5 "because it's shorter and MD5's preimages aren't broken." A malicious patient can upload documents. Explain the specific attack this enables and which property (and which cost, in powers of 2) makes it feasible on MD5 but not on SHA-256.

Exercise 3. Write verify_download(path, expected_fingerprint) that computes a file's SHA-256 in blocks (without loading it entirely) and returns True/False by comparing it against expected_fingerprint (hex, possibly with mixed case and surrounding whitespace). State, with reasoning, whether you need secrets.compare_digest.

Solutions

Solution 1. It prints True False True (the False with overwhelming probability). h1 == h2 is True due to determinism: same input, same output. h1[:8] == h3[:8] is False due to the avalanche effect: a single differing input bit changes ~half the output bits unpredictably, so not even the first 8 hex characters match (the probability of a chance match is 16^-8, about one in 4.3 billion). len(h1) == len(h3) is True due to the fixed-length output: always 64 hex characters.

Solution 2. The patient runs a collision attack (not a preimage attack): they generate two distinct documents with the same MD5 on their own computer — for example, a harmless consent form and one with altered clauses; chosen-prefix MD5 collision attacks make exactly this feasible in seconds or minutes. They upload the harmless one, which gets recorded by its fingerprint, and later submit the malicious one: the system, which identifies by MD5, treats them as the same document. It's feasible because collision resistance is worth only ~2^(n/2) — for MD5, 2^64 in theory and nearly free in practice because the algorithm is also structurally broken. On SHA-256 it would cost ~2^128, infeasible. The key point: when the attacker can choose the content that enters the system, the property protecting you is collision resistance, not preimage resistance.

Solution 3.

import hashlib

def verify_download(path: str, expected_fingerprint: str) -> bool:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        while True:
            chunk = f.read(1024 * 1024)
            if not chunk:
                break
            h.update(chunk)
    return h.hexdigest() == expected_fingerprint.strip().lower()

strip().lower() normalizes the fingerprint pasted in from outside (hexdigest()'s hex comes out lowercase). No need for compare_digest: a public download's fingerprint isn't a secret — the attacker already knows it —, so there's nothing a timing attack could leak. (Using it wouldn't be a mistake either, just unnecessary.)

Conclusion

You now have the bare primitive: a function that is deterministic, fixed-output, avalanche-effected, and resistant to preimages (~2^n) and collisions (~2^(n/2), thanks to the birthday paradox — a Module 1 debt now paid). You know what to use (SHA-256 by default, SHA-512/BLAKE2 when performance matters, SHA-3 as plan B) and what's dead (MD5 because of Flame, SHA-1 because of SHAttered). And you know its territory: content fingerprints — checksums, deduplication, git, MediNube's exports — as long as the fingerprint lives somewhere the attacker doesn't control.

But we left a question open on purpose: if the fingerprint and the message travel together, the attacker replaces both and nobody notices — a plain hash doesn't authenticate, because it has no secret inside it. The fix is to mix a shared key into the calculation, and to do it right, because the intuitive shortcut hash(key + message) is broken in a very instructive way. That's lesson 03-02: Message Authentication with HMAC, where we'll also settle two debts: signing MediNube's outgoing webhooks and no longer storing the recovery token from 01-03 in the clear. See you there.

© Copyright 2026. All rights reserved