Cryptography · lesson 1 of 8
Beginner
2Intermediate
5Also part of Cryptography
9- + Python Security from Python
- + Java Security from Java
- + Data Security and PII: Hashing, Masking, and Tokenization from Data Engineering
- + FastAPI Authentication: Password Hashing, OAuth2 and JWT from FastAPI
- + JWT Authentication in Spring Boot from Java
- + JavaScript Security Best Practices from JavaScript
- + Secrets: Real Log Masking, and the Base64 Trick That Defeats It from GitHub Actions
- + sensitive = true: What It Hides, and What It Doesn't from Terraform
- + Variable Precedence: host_vars Beats group_vars, and extra-vars Beats Everything from Ansible
Also part of: Security Fundamentals
Hashes and HMACs: What Each One Actually Proves
Run SHA-256 and HMAC in Python: one input bit flips half the digest, a recomputed hash fools a naive check, and only the keyed HMAC rejects the forgery.
By SkillByExample · Published
A hash and an HMAC both turn data into a short fixed-size value, and both are called “integrity checks”.
They prove very different things, and confusing them is one of the most common cryptographic mistakes in
real code. Every example here uses only Python’s standard library. Run them in a throwaway container with
docker run --rm -it -v "$PWD":/work -w /work python:3.12-slim bash. They were run with Python 3.12.15 on
Debian 13.7.
One bit in, half the digest out
A cryptographic hash maps any input to a fixed-size digest, and a tiny change to the input should change the output beyond recognition. This is the avalanche effect.
# avalanche.py
import hashlib
a = b"pay alice amount=100"
b = b"pay alice amount=101" # '0' -> '1' flips one bit of the last byte
ha = hashlib.sha256(a).digest()
hb = hashlib.sha256(b).digest()
print(ha.hex())
print(hb.hex())
in_bits = bin(a[-1] ^ b[-1]).count("1")
out_bits = bin(int.from_bytes(ha) ^ int.from_bytes(hb)).count("1")
print(f"input bits changed: {in_bits}")
print(f"output bits changed: {out_bits} of 256")
big = hashlib.sha256(b"x" * 10_000_000).digest()
print("digest of 10 MB is still", len(big), "bytes")
$ python avalanche.py
9b89376febcad4cb15ac5f543ab315951c5c917a2af3e1b4a6fd50f9f742c34a
c4fd9789e2670896acc81c3d31245e9fbfc107569916d41744dcbff64e78e621
input bits changed: 1
output bits changed: 131 of 256
digest of 10 MB is still 32 bytes
The ASCII codes for 0 and 1 are 0x30 and 0x31, so the two inputs differ in exactly one bit. The
digests share nothing you can see, and 131 of 256 bits changed, close to the half you’d expect from random
output. That’s what makes a hash useful as a fingerprint: you can’t tweak an input to nudge its digest
towards a target. The digest is always 32 bytes, whether the input is 20 bytes or 10 MB.
A plain hash can’t stop someone who can recompute it
Here a sender attaches the SHA-256 of each message, and the receiver checks it. The question is who the check protects against.
# plain_hash.py
import hashlib
def send(msg: bytes) -> tuple[bytes, str]:
return msg, hashlib.sha256(msg).hexdigest()
def receive(msg: bytes, digest: str) -> str:
ok = hashlib.sha256(msg).hexdigest() == digest
return "accepted" if ok else "rejected"
msg, digest = send(b"pay alice amount=100")
print("genuine: ", receive(msg, digest))
# someone on the path rewrites the message AND recomputes the hash
evil = b"pay mallory amount=9000"
print("tampered, old hash:", receive(evil, digest))
print("tampered, new hash:", receive(evil, hashlib.sha256(evil).hexdigest()))
$ python plain_hash.py
genuine: accepted
tampered, old hash: rejected
tampered, new hash: accepted
The third line is the failure. SHA-256 has no secret in it, so anyone who can change the message can compute a matching digest for their version, and the receiver accepts it. A hash detects accidental change, such as a flipped bit or a truncated download, and attacker changes only when the digest travels over a channel the attacker can’t touch. When the message and its digest travel together, the digest proves nothing about who wrote it.
An HMAC needs the key
HMAC (RFC 2104) is a standard construction that mixes a secret key into a hash. Without the key, nobody can produce a tag that verifies.
# hmac_tag.py
import hashlib, hmac, secrets
key = secrets.token_bytes(32) # shared by sender and receiver only
def tag(msg: bytes) -> bytes:
return hmac.new(key, msg, hashlib.sha256).digest()
def receive(msg: bytes, t: bytes) -> str:
return "accepted" if hmac.compare_digest(tag(msg), t) else "rejected"
msg = b"pay alice amount=100"
t = tag(msg)
print("genuine: ", receive(msg, t))
evil = b"pay mallory amount=9000"
print("tampered, old tag: ", receive(evil, t))
print("tampered, sha256: ", receive(evil, hashlib.sha256(evil).digest()))
guess = hmac.new(secrets.token_bytes(32), evil, hashlib.sha256).digest()
print("tampered, wrong key:", receive(evil, guess))
$ python hmac_tag.py
genuine: accepted
tampered, old tag: rejected
tampered, sha256: rejected
tampered, wrong key: rejected
The attack from the previous example no longer works: a plain SHA-256 of the forged message isn’t a valid
tag, and neither is an HMAC made with any other key. The key is generated fresh with secrets on each run;
in a real system it comes from a secret store. Two details matter here:
- Use
hmac.new, notsha256(key + message). The home-made version is open to length-extension attacks with SHA-256. HMAC’s construction was designed to avoid them, so don’t invent your own. - Compare with
hmac.compare_digest. Plain==can return as soon as one byte differs, so its timing leaks how much of a guessed tag was right.compare_digesttakes the same time whatever the inputs.
Where a plain hash is the right tool
A hash is correct when the expected digest comes from somewhere the attacker can’t write: a vendor publishing a release checksum on its HTTPS site, or a manifest you stored separately.
# checksum_file.py
import hashlib, pathlib
def sha256_of(path: pathlib.Path) -> str:
with path.open("rb") as f:
return hashlib.file_digest(f, "sha256").hexdigest()
path = pathlib.Path("release.tar")
path.write_bytes(b"pretend this is a release archive\n" * 1000)
published = sha256_of(path) # in real life: from the vendor's HTTPS page
print("download matches:", sha256_of(path) == published)
data = bytearray(path.read_bytes())
data[20_000] ^= 0x01 # one bit flipped in transit or on disk
path.write_bytes(data)
print("after one bit flip:", sha256_of(path) == published)
$ python checksum_file.py
download matches: True
after one bit flip: False
hashlib.file_digest (Python 3.11+) streams the file, so it works on archives larger than memory. A
single flipped bit in 34,000 bytes is caught. Using == is fine here, because a published checksum isn’t
a secret. Here’s the summary of what each one proves:
| Tool | Detects accidents | Stops someone who can rewrite data and check value | Needs a secret |
|---|---|---|---|
| SHA-256 | yes | only if the digest comes over a trusted channel | no |
| HMAC-SHA256 | yes | yes, if they don’t have the key | shared key |
| Signature (lesson 5) | yes | yes, and anyone can verify | private key to sign |
Avoid MD5 and SHA-1 for anything security-related: practical collisions exist for both. And for storing passwords, a fast hash of any kind is the wrong tool, as lesson 4 measures.
Practice
- Compare the avalanche across algorithms. Change
avalanche.pyto run the same one-bit change throughsha256,sha3_256,blake2bandmd5, and print how many bits changed out of each digest’s size.
Solution
# avalanche_algos.py
import hashlib
a, b = b"pay alice amount=100", b"pay alice amount=101"
for name in ["sha256", "sha3_256", "blake2b", "md5"]:
ha = hashlib.new(name, a).digest()
hb = hashlib.new(name, b).digest()
bits = len(ha) * 8
changed = bin(int.from_bytes(ha) ^ int.from_bytes(hb)).count("1")
print(f"{name:9} {changed:3} of {bits} bits changed")
$ python avalanche_algos.py
sha256 131 of 256 bits changed
sha3_256 130 of 256 bits changed
blake2b 284 of 512 bits changed
md5 62 of 128 bits changed
Every one of them shows a good avalanche, MD5 included. MD5 isn’t broken because its output looks predictable; it’s broken because researchers can construct two different inputs with the same digest. The avalanche effect is necessary for a secure hash, but not sufficient.
- Verify a webhook signature. Services such as GitHub sign webhook bodies with an HMAC and send it in
a header like
sha256=<hex>. Writesignandverifyfunctions and show that an edited body and a missing header are both rejected.
Solution
# webhook.py
import hashlib, hmac, secrets
SECRET = secrets.token_bytes(32) # demo-only: per run; real ones live in a secret store
def sign(body: bytes) -> str:
return "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
def verify(body: bytes, header: str) -> bool:
return hmac.compare_digest(sign(body), header)
body = b'{"event": "push", "ref": "main"}'
header = sign(body)
print(header[:23] + "...")
print("genuine:", verify(body, header))
print("edited: ", verify(body.replace(b"main", b"prod"), header))
print("no sig: ", verify(body, ""))
$ python webhook.py
sha256=be90e186e1c314d3...
genuine: True
edited: False
no sig: False
Verify the raw request bytes, before any JSON parsing. Re-serialising parsed JSON can change whitespace or key order, and then even a genuine body fails the check.
- Reject a replayed message. A valid message and its tag can be captured and sent again, and the HMAC still verifies. Put a random message ID inside the tagged data, and have the receiver refuse an ID it has already seen.
Solution
# replay.py
import hashlib, hmac, secrets
key = secrets.token_bytes(32)
seen: set[str] = set()
def tag(msg: bytes) -> bytes:
return hmac.new(key, msg, hashlib.sha256).digest()
def receive(msg: bytes, t: bytes) -> str:
if not hmac.compare_digest(tag(msg), t):
return "rejected: bad tag"
msg_id = msg.split(b";")[0].decode()
if msg_id in seen:
return "rejected: replay"
seen.add(msg_id)
return "accepted"
msg = f"id={secrets.token_hex(8)};pay alice amount=100".encode()
t = tag(msg)
print(receive(msg, t))
print(receive(msg, t)) # the same bytes, captured and sent again
$ python replay.py
accepted
rejected: replay
The ID is inside the tagged bytes, so an attacker can’t change it without breaking the tag. Real systems usually add a timestamp too, so the receiver only has to remember IDs for a short window.
- Use keyed BLAKE2 as a MAC. BLAKE2 has a built-in key parameter, so it can act as a MAC without the
HMAC wrapper. Rewrite
tagwithhashlib.blake2b(..., key=key, digest_size=32)and check a genuine and a tampered message.
Solution
# blake2_mac.py
import hashlib, hmac, secrets
key = secrets.token_bytes(32)
def tag(msg: bytes) -> bytes:
return hashlib.blake2b(msg, key=key, digest_size=32).digest()
msg = b"pay alice amount=100"
t = tag(msg)
print("tag length:", len(t))
print("genuine: ", hmac.compare_digest(tag(msg), t))
print("tampered:", hmac.compare_digest(tag(b"pay alice amount=900"), t))
$ python blake2_mac.py
tag length: 32
genuine: True
tampered: False
Keyed BLAKE2 is a vetted MAC mode of the hash, documented in RFC 7693, unlike sha256(key + message). Use
HMAC-SHA256 when you need to interoperate, because every platform has it, and keyed BLAKE2 when both ends
are yours and speed matters.