Lesson 5 · Intermediate · 25 min
Make a tamper-evident log with HMAC
Seal each log entry with a keyed hash chained to the one before it, so any later edit or deletion shows.
✓ Last checked: November 2026 · code run, output real, facts checked
In this lesson
- What a hash and an HMAC are, in plain terms
- Chain records so that removing one breaks the next
- Compare secrets safely with hmac.compare_digest
- Keep keys out of the code
A log of who did what, and when, is only useful if no one can quietly change it afterwards. Python's standard library has what you need to make a log that shows tampering: hashlib and hmac.
A hash turns any text into a fixed-length fingerprint; change one character and the fingerprint changes completely. An HMAC is a hash mixed with a secret key, so only someone who holds the key can produce a valid fingerprint. Here each entry's fingerprint, its seal, also includes the seal of the entry before it. That chain is what makes a deleted entry visible too.
The code
"""A tamper-evident log: each entry is sealed with an HMAC that also covers the entry before it."""
import hashlib
import hmac
import json
import os
# In real use the key comes from outside the code, e.g. an environment variable.
SECRET_KEY = os.environ.get("AUDIT_KEY", "practice-key-only").encode()
def seal(entry: dict, previous_seal: str) -> str:
message = previous_seal + json.dumps(entry, sort_keys=True)
return hmac.new(SECRET_KEY, message.encode(), hashlib.sha256).hexdigest()
def add(log: list, entry: dict) -> None:
previous = log[-1]["seal"] if log else "START"
log.append({"entry": entry, "seal": seal(entry, previous)})
def verify(log: list) -> list:
"""Return the positions of entries whose seal no longer matches."""
problems, previous = [], "START"
for i, item in enumerate(log, start=1):
if not hmac.compare_digest(item["seal"], seal(item["entry"], previous)):
problems.append(i)
previous = item["seal"]
return problems
log = []
add(log, {"time": "2026-11-02 08:15", "user": "collector_a", "action": "issue OR", "or": 7650101, "amount": "4500.00"})
add(log, {"time": "2026-11-02 08:40", "user": "collector_a", "action": "issue OR", "or": 7650102, "amount": "1250.75"})
add(log, {"time": "2026-11-02 09:05", "user": "collector_a", "action": "cancel OR", "or": 7650103, "amount": "0.00"})
add(log, {"time": "2026-11-02 16:30", "user": "treasurer", "action": "deposit", "amount": "5750.75"})
for i, item in enumerate(log, start=1):
e = item["entry"]
print(f"{i}. {e['time']} {e['action']:<10}{e['amount']:>9} seal {item['seal'][:12]}...")
print("\nCheck 1:", verify(log) or "all seals match")
# Someone quietly edits the amount on the second receipt
log[1]["entry"]["amount"] = "250.75"
print("Check 2:", verify(log) or "all seals match")
# Put the amount back, then remove the cancelled receipt from the log instead
log[1]["entry"]["amount"] = "1250.75"
del log[2]
print("Check 3:", verify(log) or "all seals match")
Output
1. 2026-11-02 08:15 issue OR 4500.00 seal be777d3a17dd... 2. 2026-11-02 08:40 issue OR 1250.75 seal a65c817ec515... 3. 2026-11-02 09:05 cancel OR 0.00 seal 82a6eeeca1bf... 4. 2026-11-02 16:30 deposit 5750.75 seal d9e9122fef5d... Check 1: all seals match Check 2: [2] Check 3: [3]
How it works
json.dumps(entry, sort_keys=True) turns the entry into text in a fixed order, so the same entry always produces the same seal.
previous_seal + ... each seal covers the one before it. Remove entry 3 and entry 4 no longer fits, which is why Check 3 points at position 3. The very last entry has nothing after it, so removing it would not show; that is one more reason to keep copies of the log elsewhere.
hmac.compare_digest compares two seals in a way that does not leak timing clues to an attacker. Use it instead of == whenever secrets are compared.
os.environ.get("AUDIT_KEY", ...) reads the key from an environment variable. The practice key in the code is only for this lesson; a real key never goes into source code or a shared folder.
Note: Tamper-evident is not tamper-proof. The log cannot stop someone from editing it, but it shows that it happened. Keep the key with someone other than the people who write the entries, and keep copies of the log elsewhere.
Try it yourself
Practice
Save the log to a JSON file with json.dump, load it back with json.load, and run verify() on the loaded copy. Then edit the file by hand and run it again.
Show answer
"""Answer: save the sealed log to a JSON file, load it back, and verify it."""
import hashlib
import hmac
import json
import os
SECRET_KEY = os.environ.get("AUDIT_KEY", "practice-key-only").encode()
def seal(entry, previous_seal):
message = previous_seal + json.dumps(entry, sort_keys=True)
return hmac.new(SECRET_KEY, message.encode(), hashlib.sha256).hexdigest()
def add(log, entry):
previous = log[-1]["seal"] if log else "START"
log.append({"entry": entry, "seal": seal(entry, previous)})
def verify(log):
problems, previous = [], "START"
for i, item in enumerate(log, start=1):
if not hmac.compare_digest(item["seal"], seal(item["entry"], previous)):
problems.append(i)
previous = item["seal"]
return problems
log = []
add(log, {"time": "2026-11-02 08:15", "action": "issue OR", "or": 7650101, "amount": "4500.00"})
add(log, {"time": "2026-11-02 08:40", "action": "issue OR", "or": 7650102, "amount": "1250.75"})
with open("audit_log.json", "w", encoding="utf-8") as f:
json.dump(log, f, indent=2)
with open("audit_log.json", encoding="utf-8") as f:
loaded = json.load(f)
print("Loaded copy:", verify(loaded) or "all seals match")
# Edit the file the way a person would in a text editor: change 1250.75 to 250.75
with open("audit_log.json", encoding="utf-8") as f:
text = f.read()
with open("audit_log.json", "w", encoding="utf-8") as f:
f.write(text.replace('"1250.75"', '"250.75"'))
with open("audit_log.json", encoding="utf-8") as f:
edited = json.load(f)
print("After editing the file:", verify(edited) or "all seals match")
Loaded copy: all seals match After editing the file: [2]