Tamper-Evident Audit Logs: What a Hash Chain Detects and What It Misses
A hash chain stored next to the data proves consistency, not integrity. A lab tampers with a 1,000-record chain six ways and shows what external checkpoints add.
An audit log records who did what to which data, and when. It is useful in two situations: when you investigate an incident, and when a customer or auditor asks you to show what happened. In both, the log is only worth as much as the reader's confidence that nobody changed it afterwards.
A common answer is to hash-chain the records so that any edit breaks the chain. That works against some changes and not others, and the difference depends on details that example code tends to skip. This guide explains what to record, how a hash chain works, and what external checkpoints and write-once storage add. A lab tampers with a 1,000-record chain six ways and runs two verifiers against each.
What to record
Each event should answer five questions, with enough context to investigate later and as little personal data as the purpose allows. The OWASP Logging Cheat Sheet is a good checklist for which events and fields matter.
| Question | Field | Notes |
|---|---|---|
| Who | Actor ID, actor type (user, API key, support staff), authentication method | Store an internal ID; resolve names and emails from the identity system when needed |
| What | Action, as a dotted verb such as role.permissions_updated |
A fixed vocabulary, versioned |
| Which | Resource type and ID | Plus a small, non-sensitive summary of the change |
| Whose | Tenant ID | Every query for a customer's export filters on it |
| When | Event time from a synchronized clock, and a server-assigned sequence number | Time for humans; the sequence number for ordering |
Add a request or trace ID so you can join the audit event with application logs, and an outcome field so failed attempts are recorded too. Do not put secrets, tokens, or full documents in the payload.
How a hash chain works
Give every record two extra fields. previous_hash holds the hash of the record before it. hash is computed from the record's own canonical bytes followed by previous_hash:
hash[n] = SHA-256( canonical_bytes(record[n]) || hash[n-1] )
hash[-1] = a fixed genesis value
Changing any record changes its hash, which no longer matches the next record's previous_hash. A verifier walks the chain and recomputes each hash.
Hashing requires the same bytes every time. If two services serialize the same JSON object with keys in a different order, they produce different hashes. RFC 8785, the JSON Canonicalization Scheme, defines one serialization for this purpose. Whatever you use, every writer and every verifier must use the same canonical form, and the form must be versioned so you can change it later.
Lab: what a chain detects
The program builds a 1,000-record chain and exports a checkpoint (the sequence number and hash of record 899) to storage that a database administrator cannot change. It then applies six kinds of tampering and runs two verifiers:
- a verifier that starts at index 1 and compares each record with its predecessor, as many published examples do;
- a verifier that starts from the genesis value and also compares the chain with the external checkpoint.
For simplicity, the lab hashes Go's encoding/json output for a fixed struct, which is deterministic for that struct but is not RFC 8785. Run it with go run .; it uses only the standard library.
// Lab: what a hash-chained audit log detects, and what it does not.
//
// Builds a 1,000-record chain, then tries four kinds of tampering against
// (1) a verifier that starts at index 1, as many examples do, and
// (2) a verifier that starts from a known genesis value and compares the
// head with a checkpoint stored outside the database.
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
)
type record struct {
Seq int
Payload json.RawMessage // canonical bytes for this lab (Go's encoding/json of a fixed struct)
PreviousHash string
Hash string
}
const genesis = "0000000000000000000000000000000000000000000000000000000000000000"
func hashOf(payload []byte, prev string) string {
h := sha256.New()
h.Write(payload)
h.Write([]byte(prev))
return hex.EncodeToString(h.Sum(nil))
}
func payload(seq int, action string) json.RawMessage {
b, _ := json.Marshal(struct {
Seq int `json:"seq"`
Tenant string `json:"tenant_id"`
Actor string `json:"actor_id"`
Action string `json:"action"`
}{seq, "tnt_42", fmt.Sprintf("usr_%d", seq%17), action})
return b
}
func build(n int) []record {
chain := make([]record, n)
prev := genesis
for i := range chain {
p := payload(i, "document.viewed")
chain[i] = record{i, p, prev, hashOf(p, prev)}
prev = chain[i].Hash
}
return chain
}
// startsAtOne mirrors the common example: it never recomputes record 0.
func startsAtOne(c []record) error {
for i := 1; i < len(c); i++ {
if c[i].PreviousHash != c[i-1].Hash {
return fmt.Errorf("link broken at %d", i)
}
if hashOf(c[i].Payload, c[i].PreviousHash) != c[i].Hash {
return fmt.Errorf("hash mismatch at %d", i)
}
}
return nil
}
// withCheckpoint verifies every record from genesis and compares the result
// with a head hash that was exported earlier to storage the DB admin cannot edit.
func withCheckpoint(c []record, checkpointSeq int, checkpointHash string) error {
prev := genesis
for i := range c {
if c[i].PreviousHash != prev {
return fmt.Errorf("link broken at %d", i)
}
if hashOf(c[i].Payload, prev) != c[i].Hash {
return fmt.Errorf("hash mismatch at %d", i)
}
prev = c[i].Hash
if i == checkpointSeq && prev != checkpointHash {
return fmt.Errorf("record %d does not match external checkpoint", i)
}
}
if len(c) <= checkpointSeq {
return fmt.Errorf("chain ends at %d, before checkpoint %d", len(c)-1, checkpointSeq)
}
return nil
}
func rehashFrom(c []record, from int) {
for i := from; i < len(c); i++ {
prev := genesis
if i > 0 {
prev = c[i-1].Hash
}
c[i].PreviousHash = prev
c[i].Hash = hashOf(c[i].Payload, prev)
}
}
func main() {
clean := build(1000)
cpSeq, cpHash := 899, clean[899].Hash // checkpoint exported after record 899
attacks := []struct {
name string
tamper func(c []record) []record
}{
{"no tampering", func(c []record) []record { return c }},
{"edit payload of record 500 only", func(c []record) []record {
c[500].Payload = payload(500, "document.exported")
return c
}},
{"edit record 500 and recompute every later hash", func(c []record) []record {
c[500].Payload = payload(500, "document.exported")
rehashFrom(c, 500)
return c
}},
{"edit payload of record 0 only", func(c []record) []record {
c[0].Payload = payload(0, "role.granted")
return c
}},
{"delete records 850-999 (crosses the checkpoint)", func(c []record) []record { return c[:850] }},
{"delete records 920-999 (after checkpoint)", func(c []record) []record { return c[:920] }},
}
fmt.Printf("%-48s | %-26s | %s\n", "tampering", "verifier starting at 1", "genesis + external checkpoint (seq 899)")
for _, a := range attacks {
c := a.tamper(append([]record(nil), clean...))
r1, r2 := "PASSES", "PASSES"
if err := startsAtOne(c); err != nil {
r1 = "detected: " + err.Error()
}
if err := withCheckpoint(c, cpSeq, cpHash); err != nil {
r2 = "detected: " + err.Error()
}
fmt.Printf("%-48s | %-26s | %s\n", a.name, r1, r2)
}
}
Output recorded on November 3, 2026 (Go 1.26.5, windows/amd64):
tampering | verifier starting at 1 | genesis + external checkpoint (seq 899)
no tampering | PASSES | PASSES
edit payload of record 500 only | detected: hash mismatch at 500 | detected: hash mismatch at 500
edit record 500 and recompute every later hash | PASSES | detected: record 899 does not match external checkpoint
edit payload of record 0 only | PASSES | detected: hash mismatch at 0
delete records 850-999 (crosses the checkpoint) | PASSES | detected: chain ends at 849, before checkpoint 899
delete records 920-999 (after checkpoint) | PASSES | PASSES
What the output shows
- Editing one record is caught by both verifiers. This is the case hash chains are designed for.
- Editing a record and recomputing every later hash passes the first verifier. Anyone who can write to the table can rebuild a valid chain. Only the external checkpoint caught it, because the rebuilt record 899 no longer matched the hash exported earlier.
- Editing record 0 passes the first verifier. A loop that starts at index 1 checks that record 1 points to record 0's stored hash, but never recomputes record 0. Starting from a known genesis value fixes it.
- Deleting records across a checkpoint is detected; deleting records after the last checkpoint is not. No verifier can notice that records which were never checkpointed are missing. The time between checkpoints is the window in which the end of the log can be silently cut off.
The lesson: a hash chain stored next to the data proves consistency, not integrity. Integrity comes from something the attacker cannot rewrite.
Checkpoints outside the database
A checkpoint is the latest sequence number and hash, written somewhere the database's administrators cannot alter. Options, roughly in order of independence:
- signing the checkpoint with a key the logging service cannot access, and storing the signature with it;
- writing checkpoints to write-once object storage in a separate account (see below);
- sending them to a separate party, such as a customer's SIEM or a timestamping service.
Checkpoint frequently. In the lab, deleting the last 80 records went unnoticed because the newest checkpoint was 100 records old. An hourly checkpoint bounds that window to an hour of events.
For large logs, a Merkle tree lets you prove that one record is included, or that a later log extends an earlier one, without re-reading everything. Certificate Transparency uses this design for public certificate logs; RFC 9162 describes the inclusion and consistency proofs.
Ordering with concurrent writers
A single chain needs a single order. If two application servers each read the current head and append to it, both new records claim the same predecessor and the chain forks. Either allocate sequence numbers and update the head inside one serialized transaction, or keep a separate chain per partition (for example per tenant) and checkpoint all partition heads together. Per-tenant chains also make it easy to give a customer their own records and checkpoint without exposing anyone else's.
Write-once storage
Hashes make tampering visible; they do not stop someone from deleting the table. Export closed batches of records and their checkpoints to storage that refuses deletion for a retention period. Amazon S3 Object Lock offers two modes, described in Locking objects with Object Lock:
- Governance mode blocks deletion and overwrites except by users with a special permission, which is useful while you are still testing retention settings.
- Compliance mode blocks deletion by every user, including the account's root user, until the retention date. AWS notes that the only way to delete such an object early is to delete the AWS account.
Choose the retention period from your legal and contractual obligations, including obligations to delete personal data, before enabling compliance mode, because you cannot shorten it later. Keep the bucket in a separate account with separate administrators, or the protection only covers the objects and not the bucket's configuration and replication.
Signing keys
If you sign checkpoints, the signing key must be outside the reach of whoever can write the log, for example in a managed key service with a policy that only the checkpoint job can use. Rotate it on a schedule you choose from its use and exposure; NIST SP 800-57 Part 1 discusses how to choose a cryptoperiod. Keep every public key you have ever used, with its validity dates, so old checkpoints remain verifiable.
Verification as a scheduled job
Run verification on a schedule and after any migration that touches the log. Start from genesis or from the last verified checkpoint, recompute every hash, compare with every checkpoint in range, and confirm that the sequence numbers have no gaps. Record each run's range, result, and verifier version. Failures should page someone; a verification job that only writes to a dashboard is easy to ignore.
What this does and does not prove to an auditor
A verified chain with external checkpoints shows that the records you hold were not changed or removed after they were checkpointed. It does not show that every event was recorded in the first place, that the clock was right, or that access to the system was controlled. Audit frameworks look at the whole set of controls around logging. Treat the chain as one piece of evidence, and keep the documentation that explains how it works.
Where to start
If you already hash-chain audit records, check two things in your verifier: whether it recomputes the first record, and whether it compares against anything stored outside the database. If both answers are no, your chain detects only careless edits. Add a genesis check, export a signed checkpoint every hour to write-once storage in a separate account, and alert on verification failures. The cross-tenant leak guide uses the same idea for storing the results of isolation tests, and the encryption keys guide covers keeping signing and encryption keys outside the application's reach.
Frequently Asked Questions
Is a database trigger that blocks UPDATE and DELETE enough?
It stops mistakes and application bugs, but anyone who can disable the trigger or connect as the table owner can still change rows. Keep the trigger, and add external checkpoints so changes by privileged users are detectable.
How often should checkpoints be exported?
Often enough that the newest records you could lose without noticing is an acceptable amount. Hourly is a common starting point; high-risk systems checkpoint every few minutes or stream records to a second party as they are written.
Do I need RFC 8785 if all my services are written in Go?
You need one canonical form that every writer and verifier agrees on, now and after future refactors. A standard such as RFC 8785 makes that easier when another language, a customer, or an auditor needs to verify the hashes independently.
How do I correct a wrong audit record?
Never edit it. Append a correction event that references the original record's sequence number and explains the change. The chain then shows both the mistake and its correction.
Can customers verify their own audit logs?
Yes, if you keep per-tenant chains and give each tenant its records, the canonicalization rules, and the signed checkpoints for its chain. The customer can then recompute hashes without trusting your verifier.
Sources
Documents cited in this guide. Each link was opened and its title compared with the label below on the date shown. A source supports the specific statements it is linked from; design recommendations and lab results are Velynoriva's own.
-
RFC 8785: JSON Canonicalization Scheme (JCS)
(Internet Engineering Task Force)
• Link checked 2026-11-03
A single serialization of JSON for hashing and signing.
-
RFC 9162: Certificate Transparency Version 2.0
(Internet Engineering Task Force)
• Link checked 2026-11-03
Merkle-tree logs with inclusion and consistency proofs.
-
Locking objects with Object Lock
(Amazon Web Services)
• Link checked 2026-11-03
Governance and compliance retention modes.
-
Logging Cheat Sheet
(OWASP Foundation)
• Link checked 2026-11-03
Which security events to record and which data to exclude.
-
SP 800-57 Part 1 Rev. 5: Recommendation for Key Management
(National Institute of Standards and Technology)
• Link checked 2026-11-03
Factors for choosing cryptoperiods for signing keys.
Revision history
- — Added a reproducible lab. Fixed the previous verification example, which started at index 1 and never checked the first record. Explained that truncation after the newest checkpoint is undetectable, removed TeX formulas that displayed as raw text, and removed the mislabeled HIPAA and SOC 2 criteria references.
- — Added external checkpoints and limits of WORM storage.