CoursesPython for security automationSecurity automation at scale

Hashing & integrity: hashlib, hmac

Fingerprint files and verify without leaks.

Intermediate20 min · lesson 11 of 14

Every file on your laptop, every release you download, every webhook that hits your server is a run of bytes. You constantly need to answer two questions about those bytes: did they arrive exactly as sent, and did they really come from who they claim? Hashing answers both, and Python hands you the tools in two small standard-library modules, hashlib and hmac.

A fingerprint for a pile of bytes

A fingerprint is tiny next to the whole person, yet no two people share one. A hash does the same trick for data. Feed any bytes through a hash function (a one-way math routine that turns any input into a fixed-size string called a digest) and you get back the same short length every time, whether the input was a one-line note or a huge disk image. Change a single byte of the input and the digest changes completely, in a way nobody can steer. The one-way part is what makes it useful: going from file to digest takes a blink, and going from digest back to the original file is not possible. That drives the two jobs in this lesson. hashlib computes digests. hmac adds a secret key so a digest also proves who produced it. Both ship with Python, so there is nothing to install.

Fingerprint a file without eating your RAM

The obvious approach reads the whole file into memory and hashes it. That is fine for a config file and a disaster for a 4 GB (gigabyte) image, because you would pull all 4 GB into RAM (random-access memory, your computer's fast working space) at once. On a build agent with little memory to spare, the process dies. The fix uses a handy property of hashes: you can build one up piece by piece, the way a cashier scans a cart one item at a time and still reaches the same total as ringing everything together. Updating the hash with chunk A and then chunk B gives the exact same digest as updating it once with A and B glued together. So you read a 64 KB (kilobyte) slice, fold it in, drop it, and read the next slice. Memory stays flat no matter how large the file grows.

checksum.py
import hashlib, sys
def sha256_of(path, chunk_size=65536):
h = hashlib.sha256()
with open(path, "rb") as f:
while chunk := f.read(chunk_size):
h.update(chunk)
return h.hexdigest()
if __name__ == "__main__":
for path in sys.argv[1:]:
print(f"{sha256_of(path)} {path}")

The `while chunk := f.read(chunk_size)` line grabs 64 KB at a time and stops when `read` returns empty bytes at the end of the file. Opening with `"rb"` gives raw bytes, which is what a hash wants, never decoded text. Run coreutils' own `sha256sum` on the same file and you get the identical string, character for character. That is the point of a standard algorithm: everyone computes the same digest for the same bytes.

~/secopslog — bash
$ python3 checksum.py app-2.4.1.tar.gz
8aa1146a0f996d43b372674327198f633348f20fb4431c78e8f6eece378f5161 app-2.4.1.tar.gz

Verify a download against a published checksum

A tamper-evident seal on a medicine bottle tells you at a glance whether the contents were opened. A published checksum is that seal for a download. The project ships the file and also publishes a small SHA256SUMS file listing the digest it expects. You hash your copy and compare. A match means the bytes on your disk are exactly the bytes the project built. A mismatch means a truncated download, a corrupted mirror, or someone who swapped the file for their own.

SHA256SUMS
8aa1146a0f996d43b372674327198f633348f20fb4431c78e8f6eece378f5161 app-2.4.1.tar.gz
verify.py
import sys
from checksum import sha256_of
expected, path = sys.argv[1], sys.argv[2]
actual = sha256_of(path)
if actual == expected:
print(f"OK: {path} matches the published checksum")
else:
print(f"FAIL: {path} does NOT match")
print(f" expected: {expected}")
print(f" actual: {actual}")
sys.exit(1)
~/secopslog — bash
$ python3 verify.py 8aa1146a0f996d43b372674327198f633348f20fb4431c78e8f6eece378f5161 app-2.4.1.tar.gz; echo "exit=$?"
OK: app-2.4.1.tar.gz matches the published checksum exit=0
$ python3 verify.py 8aa1146a0f996d43b372674327198f633348f20fb4431c78e8f6eece378f5161 app-tampered.tar.gz; echo "exit=$?"
FAIL: app-tampered.tar.gz does NOT match expected: 8aa1146a0f996d43b372674327198f633348f20fb4431c78e8f6eece378f5161 actual: 7b7df622ae658f2b9eccf7341bff9fcc62d27fd4afce640823397917ee71c513 exit=1

The exit code matters in a pipeline. verify.py returns 0 on a match and 1 on a mismatch, so a CI (continuous integration, the automated build-and-test step that runs on every commit) job fails loudly instead of shipping a bad artifact. One honest limit sits underneath all of this: a checksum only helps if you fetched it from somewhere you trust, over HTTPS (HyperText Transfer Protocol Secure, an encrypted web connection) from the project's own site, not from the same mirror as the file. An attacker who can replace the download can replace a checksum sitting next to it. Hashes catch accidents and lazy tampering; cryptographic signatures (GPG, short for GNU Privacy Guard) close the gap against an attacker who controls the mirror. Treat the checksum as your first layer, not your only one.

HMAC: a fingerprint only the keyholder can forge

A plain hash has one gap: anyone can compute it, so it proves nothing about who made it. You and a friend agree on a secret knock. Anyone can bang on your door, but only someone who knows the pattern taps out the right rhythm, so the knock itself tells you who is there. HMAC (Hash-based Message Authentication Code, a hash mixed with a shared secret key) does the same for data. Here is the gap it closes: if a webhook (an automated HTTP call one service sends another when an event happens) attached a plain sha256 of its body, an attacker could invent a fake event, attach a matching hash, and you could not tell the forgery from the real thing. With HMAC, you and the sender agree on a secret ahead of time. The sender hashes the message together with the secret; you repeat the calculation and check that the two line up. Without the secret, nobody can produce the right digest, so a valid HMAC proves two things at once: the message was not altered, and it came from someone holding the key. GitHub signs every webhook this way. Set a webhook secret and each delivery arrives with a header, X-Hub-Signature-256: sha256=<hmac>, that your endpoint recomputes and checks.

webhook.py
import hashlib, hmac
# The secret you configured in GitHub -> Webhooks. In real code, read it from an
# environment variable or a secrets manager, never hard-code it.
SECRET = b"s3cr3t-webhook-key"
# The exact raw request body GitHub sent (bytes, not the parsed JSON object).
payload = b'{"action":"opened","number":42}'
# What GitHub puts in the header, computed on its side with the same secret.
received_header = "sha256=" + hmac.new(SECRET, payload, hashlib.sha256).hexdigest()
def is_valid(secret, body, header):
if not header.startswith("sha256="):
return False
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
sent = header.split("=", 1)[1]
return hmac.compare_digest(expected, sent) # constant-time, not ==
print("X-Hub-Signature-256:", received_header)
print("valid payload ->", is_valid(SECRET, payload, received_header))
forged = b'{"action":"opened","number":999}'
print("forged payload ->", is_valid(SECRET, forged, received_header))
wrong_key = "sha256=" + hmac.new(b"guessed-key", payload, hashlib.sha256).hexdigest()
print("wrong secret ->", is_valid(SECRET, payload, wrong_key))
~/secopslog — bash
$ python3 webhook.py
X-Hub-Signature-256: sha256=49f74d28ce940ec81e3c92041b925b9dbf1f20fab34c44a2805b8d1ce78a7a46 valid payload -> True forged payload -> False wrong secret -> False

One detail decides whether this works: hash the raw bytes of the request body, exactly as they arrived, before any JSON (JavaScript Object Notation, a common text format for structured data) parsing. If you parse the body and then re-serialize it, key order or spacing can shift, the bytes change, and your HMAC will not match even for a genuine request. GitHub signed the precise bytes on the wire, so those are the bytes you feed to hmac.new.

Verifying a GitHub webhook signature
1Event fires
a pull request opens; GitHub builds the JSON body
2GitHub signs
HMAC-SHA256 over the raw body using the shared secret
3Delivery
POST arrives with header X-Hub-Signature-256: sha256=...
4You recompute
HMAC the raw bytes you received, with the same secret
5compare_digest
constant-time match accepts; anything else is dropped
The secret never travels with the request. Both sides hold it, both compute the HMAC over the same raw bytes, and only the digest is sent and compared.

Compare secrets in constant time

Notice the code compares with hmac.compare_digest, not ==. A cheap combination lock that clicked a little louder as each correct digit dropped into place would be easy to crack: you would listen your way through one digit at a time. A normal string compare has the same leak. == stops at the first byte that differs, so a forged signature whose first byte happens to be right takes a hair longer to reject than one that is wrong immediately. An attacker who can measure those tiny differences across many requests can rebuild the correct signature byte by byte. That is a timing attack. compare_digest checks the full length every time, so its runtime says nothing about how close a guess was. Use it for every secret comparison you make: webhook signatures, session tokens, API (application programming interface) keys.

Three ways to get hashing wrong

First, md5 and sha1 are broken for security. Researchers can craft two different inputs that produce the same md5 or sha1 digest, a collision, which means the digest no longer stands uniquely for its input. Here is how easy all three are to reach in Python:

~/secopslog — bash
$ python3 -c 'import hashlib; d=b"hello"; [print(a, getattr(hashlib,a)(d).hexdigest()) for a in ("md5","sha1","sha256")]'
md5 5d41402abc4b2a76b9719d911017c592 sha1 aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d sha256 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824

md5 and sha1 are fine for a non-security job like a cache key or spotting an accidental duplicate file. For anything an attacker might target, such as verifying a download or signing a webhook, use sha256 or stronger and never the older two.

Second, plain hashlib is wrong for passwords. sha256 is built to be fast, and a modern GPU (graphics processing unit, a chip that runs enormous numbers of calculations in parallel) can try billions of sha256 guesses a second. That speed is the enemy: if your user database leaks, it lets the attacker brute-force every password quickly. Password storage needs a slow, salted, purpose-built function: bcrypt or argon2 (installed with pip install bcrypt or argon2-cffi). They are deliberately slow and mix a unique random salt (extra random bytes) into each password, so two identical passwords store as different digests. If you truly cannot add a dependency, hashlib's own pbkdf2_hmac and scrypt are acceptable fallbacks, but reach for argon2 first. Third, always hash the raw bytes you received, never a parsed-and-rebuilt copy, or a genuine signature check will fail on you.

hashlib is not a password store
sha256(password), even with a salt you bolt on yourself, is brute-forced in no time because sha256 is fast by design. Store passwords with bcrypt or argon2 (or hashlib.pbkdf2_hmac / scrypt as a fallback), which are slow and salted on purpose.
Quick check
01Your webhook receiver compares the signature it computes against the one in the request header. Why should it use hmac.compare_digest instead of the == operator?
Incorrect — Speed is not the point; compare_digest deliberately checks every byte and is not chosen for performance.
Incorrect — == handles differing lengths fine and returns False; that is not the risk here.
Correct — The early exit is a timing side channel an attacker can measure to rebuild the signature byte by byte, so secret comparisons must run in constant time.
Incorrect — It only compares the two digests you hand it; it knows nothing about keys or secrets.
02The lesson warns against storing passwords with a plain hashlib call like sha256(password). What actually makes sha256 the wrong tool for that job?
Incorrect — sha256 is one-way and cannot be reversed; the real danger is fast brute-forcing of guesses, not decryption.
Correct — Its speed is exactly the weakness, which is why password storage needs a deliberately slow, salted function like bcrypt or argon2.
Incorrect — You can bolt a salt onto sha256; the lesson says even a salted sha256 is brute-forced in no time because the algorithm is fast.
Incorrect — The 256-bit digest length is fine; the problem is the algorithm's speed, not the size of its output.
03Your webhook receiver parses the request body into a dict, re-serializes it with json.dumps, and computes an HMAC (Hash-based Message Authentication Code) over that string. Genuine GitHub deliveries keep failing the signature check. What is the cause?
Incorrect — A wrong secret is a different failure; the bug described is the re-serialization, and the fix is to hash the raw body rather than rotate keys.
Incorrect — json.dumps does no hashing at all; the mismatch comes from changed bytes, not a different algorithm.
Correct — HMAC is computed over exact bytes, so parsing and rebuilding the payload breaks a valid signature.
Incorrect — The code strips that prefix before comparing; the real failure is the altered payload bytes, not the prefix.

Related