Verifying SHA-256 audit hashes across compliance records
An auditor does not ask whether your ledger has hashes. They ask you to prove, right now, on a specific batch of records for a specific site, that nothing was altered after it was sealed — and if something was, exactly which record and which field. The Audit Log Schema Reference defines the AuditRecord shape and the append-time chaining rule that makes this provable in principle; this page is the other half of that contract — the verification walk you run against an already-persisted batch, whether that batch just came out of cold storage, arrived as a compliance export for site TWR-8842, or is sitting in a database table a municipal reviewer wants checked on the spot. The task is mechanical but exacting: recompute every record’s canonical hash from its own stored payload, compare it to what was persisted, walk the prev_hash chain link by link, and the moment one link fails, stop and report exactly where.
Prerequisites & Context
This walkthrough needs Python 3.10 or newer and nothing beyond the standard library — json, hashlib, and logging cover the entire task, which is deliberate: a verifier that depends on a third-party package is one more thing that has to be trusted and kept available during an audit. You also need the batch itself, already loaded into memory as a list of dictionaries matching the AuditRecord shape — record_id, site_id, actor, action, canonical_payload, audit_hash, prev_hash, ingested_at — exactly as defined in the parent reference. Where those canonical_payload fields came from in the first place, and how a vendor’s own field names get normalized into that shape before a record is ever sealed, is covered in Mapping vendor fields to the canonical tower schema; this page assumes that normalization already happened and the record is sitting in the ledger, sealed.
One more precondition matters more than it looks: who is allowed to run this verification at all. canonical_payload can carry landlord and technician identifiers alongside operational fields, so pulling a raw batch out of storage to hash-check it is itself an access event, governed by the same rules Security Boundary Configuration applies to any other read of compliance data. A verification script that can read a batch is a verification script that can read everything in it, so it should run under the same role restrictions as any other consumer of the ledger — never as an unaudited side-channel that bypasses them because “it’s just checking hashes.”
With Python, the batch, and the right access in place, this task sits inside the wider Telecom Compliance Data Reference, as the concrete check that turns “the schema is tamper-evident in theory” into “this specific batch was, or was not, tampered with.”
Step-by-Step Implementation
Step 1 — Load the record batch. Pull the site’s records out of storage in ledger order — the order they were appended, not the order a query happens to return them — and keep them as a plain list of dictionaries. Verification depends entirely on walking that list in sequence; a batch that arrives shuffled by a SELECT without an ORDER BY will report chain breaks that are really just a sorting bug, not tampering.
batch = json.load(open(f"{site_id}_batch.json"))
Step 2 — Recompute each record’s canonical hash. For every record, serialize its canonical_payload with json.dumps(payload, sort_keys=True, separators=(",", ":")) — the same canonical form the ledger used when it first sealed the record — encode it to bytes, append the record’s own prev_hash bytes, and run the result through hashlib.sha256. Anything else — hashing the dict in insertion order, hashing with default separators, hashing the payload without prev_hash appended — produces a digest that will never match a correctly sealed record, which is why the canonical form has to be reproduced exactly, not approximately.
digest_input = canonical_bytes(record["canonical_payload"]) + record["prev_hash"].encode("utf-8")
recomputed = hashlib.sha256(digest_input).hexdigest()
Step 3 — Compare against the stored audit_hash. If recomputed does not equal the record’s stored audit_hash, the payload you are holding is not the payload that was sealed — full stop. This is the check that catches a field edited directly in storage: the audit_hash column still holds whatever was computed at seal time, but the canonical_payload next to it now serializes to different bytes, so the two no longer agree.
Step 4 — Walk the hash chain. Independently of Step 3, confirm that each record’s prev_hash equals the audit_hash you already verified for the record immediately before it in ledger order, starting from the sixty-four-zero genesis constant for the first record. This catches a different failure mode than Step 3: a record can have an internally consistent, correctly recomputed hash and still be the wrong record for its position — deleted, reordered, or spliced in from a different chain entirely.
Step 5 — Report the first broken link and stop the line. The moment either check fails, raise immediately and identify the offending record_id — do not continue hashing the rest of the batch. Every record after a broken link is unverifiable regardless of whether its own hash happens to recompute correctly, because the chain’s guarantee is transitive: once one link is broken, nothing downstream of it can be trusted to describe genuine, unaltered history. Reporting “records 4 through 12 also look fine” after record 3 failed would be actively misleading.
Figure: the walk halts the instant record R2’s recomputed hash disagrees with its stored value — R3 is never checked because a broken link makes everything after it unverifiable.
Complete Runnable Example
The block below is self-contained on Python 3.10+ with no third-party dependencies. It carries the three mandatory pieces for this codebase — structured logging via logging, the custom AuditVerificationError exception, and a hashlib.sha256 audit hash — and drives them against a clean, three-record chain for site TWR-8842: a lease being sealed, a zoning override being approved, and a rent escalation being applied.
import json
import hashlib
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("audit_verifier")
GENESIS_HASH = "0" * 64
class AuditVerificationError(Exception):
"""Raised when a recomputed hash or a prev_hash chain link fails to match."""
def canonical_bytes(payload: dict) -> bytes:
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
def recompute_hash(record: dict) -> str:
digest_input = canonical_bytes(record["canonical_payload"]) + record["prev_hash"].encode("utf-8")
return hashlib.sha256(digest_input).hexdigest()
def verify_batch(records: list) -> None:
expected_prev = GENESIS_HASH
for record in records:
record_id = record["record_id"]
recomputed = recompute_hash(record)
if recomputed != record["audit_hash"]:
log.error("hash mismatch at %s", record_id)
raise AuditVerificationError(
f"{record_id}: recomputed hash {recomputed[:12]} != stored "
f"audit_hash {record['audit_hash'][:12]}"
)
if record["prev_hash"] != expected_prev:
log.error("chain break at %s", record_id)
raise AuditVerificationError(
f"{record_id}: prev_hash {record['prev_hash'][:12]} != prior "
f"audit_hash {expected_prev[:12]}"
)
log.info("verified %s ok=%s", record_id, recomputed[:12])
expected_prev = record["audit_hash"]
log.info("chain intact across %d records", len(records))
if __name__ == "__main__":
batch = [
{"record_id": "d4e1f9a2", "site_id": "TWR-8842", "action": "LEASE_RECORD_SEALED",
"canonical_payload": {"jurisdiction_code": "MUN-TX-014", "rent_amount_usd": 4200.0,
"lease_expiry": "2029-03-31"},
"prev_hash": GENESIS_HASH,
"audit_hash": "684f6c95bfa20e7c3843062aa78abd28fea194df9a13681f1f0feb680e970b39"},
{"record_id": "9b2c6f0d", "site_id": "TWR-8842", "action": "ZONING_OVERRIDE_APPROVED",
"canonical_payload": {"jurisdiction_code": "MUN-TX-014", "height_limit_ft": 185.5,
"override_reason": "structural_variance_granted"},
"prev_hash": "684f6c95bfa20e7c3843062aa78abd28fea194df9a13681f1f0feb680e970b39",
"audit_hash": "2cacd542e0c1519997441501d19f980276be93be2d1b8a187cee3ad5a4015e13"},
{"record_id": "6f1a8c3d", "site_id": "TWR-8842", "action": "RENT_ESCALATION_APPLIED",
"canonical_payload": {"escalation_pct": 3.5, "rent_amount_usd": 4347.0,
"effective_date": "2026-06-01"},
"prev_hash": "2cacd542e0c1519997441501d19f980276be93be2d1b8a187cee3ad5a4015e13",
"audit_hash": "4eedf601309d1306a6dbc29b30f394eaaaa560b1f4830d558a93203e32c5aa33"},
]
try:
verify_batch(batch)
print(json.dumps({"status": "CHAIN_VALID", "records_checked": len(batch)}, indent=2))
except AuditVerificationError as exc:
print(json.dumps({"status": "CHAIN_BROKEN", "stopped_at": str(exc)}, indent=2))
Verification & Expected Output
Save the block as verify_batch.py and run python3 verify_batch.py. Against the clean batch above, the output is exactly:
INFO verified d4e1f9a2 ok=684f6c95bfa2
INFO verified 9b2c6f0d ok=2cacd542e0c1
INFO verified 6f1a8c3d ok=4eedf601309d
INFO chain intact across 3 records
{
"status": "CHAIN_VALID",
"records_checked": 3
}
Now tamper the second record the way it happens in practice: someone edits a stored value directly — bump height_limit_ft from 185.5 to 210.0 in record 9b2c6f0d — without recomputing and rewriting audit_hash. Re-run the script and the output changes at exactly the point of the edit:
INFO verified d4e1f9a2 ok=684f6c95bfa2
ERROR hash mismatch at 9b2c6f0d
{
"status": "CHAIN_BROKEN",
"stopped_at": "9b2c6f0d: recomputed hash a5d813a5fa13 != stored audit_hash 2cacd542e0c1"
}
The first record still verifies, because it was never touched. The second record’s recomputed digest — a5d813a5fa13bf734bb521a5e087a06345d76ef1f4b4239b154a2906621e7e99 — no longer matches the audit_hash that was persisted alongside it — 2cacd542e0c1519997441501d19f980276be93be2d1b8a187cee3ad5a4015e13 — because the payload bytes going into hashlib.sha256 changed the instant height_limit_ft changed. The third record is never even reached; verify_batch raises on record two and the function returns control to the caller before record three’s hash or chain link is ever inspected. That is the whole point of stopping the line: a report that said “record three verifies fine” would be true only in the narrow sense that its own hash recomputes correctly — it would say nothing about whether record three genuinely followed record two in an unaltered history, because record two is no longer trustworthy enough to anchor that claim.
Gotchas & Edge Cases
- Stopping at the first break is a feature, not a shortcut. It is tempting to make the verifier collect every mismatch in the batch and report them all at once, the way the FCC lease mapper reports every schema violation in one pass. Chain verification is different: once one link is broken, every subsequent record’s “pass” is meaningless, because its correctness is defined relative to a predecessor you can no longer trust. Continuing past the first break and reporting downstream records as verified gives a false sense that only one record was affected, when in fact nothing after the break can be vouched for at all.
- Float serialization has to be exact, not just numerically equal.
json.dumpsrenders185.5and185.50identically, but a payload that storedheight_limit_ftas the string"185.5"at seal time and gets reloaded as the float185.5will serialize differently enough to break the hash, even though a human reading the two values would call them the same fact. Canonical serialization has to reproduce not just the value but the exact type the sealing service used — verify against the same JSON decoding path production uses, not a hand-rolled reload that coerces types along the way. - A batch spanning more than one site will falsely report a break at the boundary.
prev_hashchains are scoped persite_id; if an export interleavesTWR-8842andTWR-9103records in ingestion order, the first record of the second site will never have aprev_hashequal to the lastaudit_hashof the first site, because they were never part of the same chain. Filter and group the batch bysite_idbefore callingverify_batch, and treat a genesisprev_hashof sixty-four zeros as the only valid start of a new site’s segment.
FAQ
Why does the script stop at the first broken link instead of scanning the whole batch and reporting every mismatch?
Because the chain’s guarantee is transitive, not independent per record. A hash-chained ledger proves integrity precisely because each record’s validity depends on the one before it; once a link breaks, nothing downstream can be verified against a trustworthy predecessor, no matter how cleanly its own hash recomputes. Reporting later records as “verified” after an earlier one failed would misstate what the chain can actually prove, so verify_batch raises AuditVerificationError on the first mismatch and leaves everything after it explicitly unverified.
Do record_id or ingested_at ever affect the recomputed hash?
No. The digest is computed only from canonical_payload and prev_hash — see the Audit Log Schema Reference’s chaining rule — so record_id and ingested_at can be regenerated, reformatted, or even redacted from a report without ever affecting whether verify_batch accepts the record. That is intentional: record_id is an identifier for humans and systems to reference the record by, and ingested_at is descriptive metadata about when the ledger accepted it, neither of which is part of the tamper-evidence guarantee itself.
What if I only have the batch as a JSON file exported from a reporting tool, not the live ledger?
That is the normal case this page is written for — a cold export, not a live database connection. As long as the export preserves the exact canonical_payload values and the audit_hash/prev_hash pair for every record in ledger order, verify_batch runs identically against a file loaded with json.load as it would against records pulled live. The one risk is an export tool that “cleans up” the JSON on the way out — reformatting numbers, dropping trailing zeros, re-ordering keys in a way that changes the underlying Python types — since that silently reintroduces the float-serialization gotcha above even on records nobody actually altered.
Related
- Parent reference: Audit Log Schema Reference
- Sibling guide: Mapping vendor fields to the canonical tower schema
- Access control for this data: Security Boundary Configuration
- Section overview: Telecom Compliance Data Reference