Build a Tamper-Evident Audit Log Service
A complete audit log service: append-only records, per-stream hash chaining, periodic checkpoints, verification endpoint, and tamper-detection tests.
Project goal
Record audit events in an append-only store where each record chains to the previous hash, so any later edit or deletion is detectable by verification.
Final features
- Canonical event
- Append with chain
- Checkpoint
- Verification
- Query API
Architecture
Each event stores the actor, action, target, timestamp, and a hash of the previous event plus its own canonical content. Verification replays a stream from a checkpoint and recomputes hashes, reporting the first divergence.
Prerequisites
- Node.js 20 LTS or later
- PostgreSQL 15+
- Understanding of hash chains
Dependencies
- pg ^8.12.0 — Append-only storage
- express ^4.21.0 — Append and verify endpoints
Directory structure
audit-log-service/
├── src/append.ts
├── src/hash-chain.ts
├── src/verify.ts
├── src/checkpoint.ts
├── tests/audit.test.ts
└── package.json
Phases
1. Canonical event
Serialize each event to a deterministic byte form before hashing.
const canonical = JSON.stringify([prev, actor, action, target, at]);
Expected behavior: Canonicalization ensures the same event always hashes the same.
2. Append with chain
Read the stream head, compute the new hash, insert in one transaction.
await db.tx(async (t) => {
const head = await t.getHead(streamId);
const hash = sha256(canonicalOf(head, event));
await t.insert({ ...event, prev: head.hash, hash });
});
Expected behavior: Serializing on the stream head prevents forks in the chain.
3. Checkpoint
Periodically sign the current head so old ranges can be pruned and still verified.
await db.insertCheckpoint({ streamId, at: head.id, hash: head.hash, signature: sign(head.hash) });
Expected behavior: Checkpoints bound verification cost and give an external anchor.
4. Verification
Replay from the last checkpoint and recompute each hash.
if (sha256(canonicalOf(prev, event)) !== event.hash) return { valid: false, at: event.id };
Expected behavior: Detecting the first mismatch localizes tampering.
5. Query API
Expose filtered reads without allowing updates or deletes.
app.get('/audit', limitAndFilter(handler)); // no PUT/DELETE routes
Expected behavior: Audit readers need queries but never mutations.
Testing
Append a sequence, verify it passes, then mutate one stored field directly in the database and assert verification reports the exact broken record id.
Troubleshooting
Verification always fails
The canonical serialization changed between append and verify; pin the field order and encoding in code.
Chain forks under load
Two appends read the same head; serialize per stream with a transaction or a lock.
Deployment
Store the chain in a database with restricted update privileges: the service account should be able to insert and select but not update. Ship checkpoints to an external, append-only sink.
Known limitations
- The chain is per stream; cross-stream ordering is not guaranteed.
- Key rotation for signing checkpoints is manual.
- The example stores payloads in plaintext columns.
Project overview
An audit log that can be edited is not an audit log. This build makes tampering detectable rather than impossible, by chaining each record to the hash of its predecessor so any later edit or deletion breaks verification at a precise point.
You will serialize each event to a canonical byte form, append it with the previous hash, sign periodic checkpoints, expose a verification endpoint that replays a stream, and serve filtered reads without any mutation routes at all.
The threat model is an insider with database access rather than a remote attacker. The design cannot stop a write, but it can make a silent change impossible to hide, which is the property auditors actually rely on.
The system is aimed at teams that already keep logs but need an answer to the question of whether a record was changed after the fact. It answers that question with arithmetic rather than with an access-control promise.
The sequence is incremental: a canonical event, then a chain, then checkpoints, then verification, then a read API, so each stage produces something you can inspect before the next layer depends on it.
What makes this build instructive is that the hard part is not cryptography but discipline: choosing what to hash, freezing that choice, and refusing to ever expose a mutation path. The interesting failures here are process failures rather than algorithm failures.
What you will build
The service delivers five capabilities, each backed by a test that appends or verifies real rows:
- Canonical event
- Append with chain
- Checkpoint
- Verification
- Query API
Canonical serialization fixes the bytes that get hashed; appending links each event to the stream head inside a transaction; checkpoints bound how far verification must replay; the verify endpoint localizes the first divergence; and the query API proves reads never require write privileges.
The chain is deliberately simple — a hash over the previous hash plus the canonical event — so a reader can reproduce a hash by hand. Complexity in this layer would weaken the very guarantee the service exists to provide.
Architecture and data model
Each event stores the actor, action, target, timestamp, and a hash of the previous event plus its own canonical content. Verification replays a stream from a checkpoint and recomputes hashes, reporting the first divergence.
Each event stores actor, action, target, timestamp, the previous event hash, and its own hash. The hash covers a canonical serialization of the new event together with the previous hash, so changing any earlier field changes every later hash in the stream.
Verification replays from the most recent checkpoint, recomputing each hash in order and stopping at the first mismatch. The checkpoint is the reason old ranges can be pruned or archived yet remain anchored to a signed head.
Streams partition events by subject, so ordering is total within a stream and irrelevant across streams. That keeps append serialization cheap — a single head per stream — without a global lock that would cap throughput.
The canonical form is a fixed-order encoding of the fields rather than a language-native serializer, because object key order is not guaranteed. Hashing an unstable representation is the single most common way a tamper-evident log silently stops verifying.
Prerequisites and dependencies
The service needs a relational store and a clear definition of what an audit event means for your domain.
- Node.js 20 LTS or later
- PostgreSQL 15+
- Understanding of hash chains
The dependency set is small and stable, which matters for a component meant to run unchanged for years:
pg(^8.12.0) — Append-only storageexpress(^4.21.0) — Append and verify endpoints
pnpm init
pnpm add pg express
pnpm add -D typescript tsx vitest
Pin the canonical serialization in code rather than in configuration, because a change to field order or encoding silently invalidates every existing hash. Keep the checkpoint signing key in a secret manager and record its id so verification can select the right key after rotation.
Directory structure
Split canonicalization, chain append, checkpointing, and verification so the hashing rules live in exactly one file:
audit-log-service/
├── src/append.ts
├── src/hash-chain.ts
├── src/verify.ts
├── src/checkpoint.ts
├── tests/audit.test.ts
└── package.json
Phase 1: Canonical event
Serialize each event to a deterministic byte form before hashing.
const canonical = JSON.stringify([prev, actor, action, target, at]);
Canonicalization ensures the same event always hashes the same.
Canonicalization is the load-bearing decision: fix the field order, the timestamp format, and the number encoding in code. Two implementations that disagree by a byte produce different hashes for identical events, which is indistinguishable from tampering.
Phase 2: Append with chain
Read the stream head, compute the new hash, insert in one transaction.
await db.tx(async (t) => {
const head = await t.getHead(streamId);
const hash = sha256(canonicalOf(head, event));
await t.insert({ ...event, prev: head.hash, hash });
});
Serializing on the stream head prevents forks in the chain.
Append inside one transaction that reads the stream head and writes the new row, so two concurrent appends cannot both chain to the same predecessor. The transaction is what makes the chain linear instead of a tree.
Phase 3: Checkpoint
Periodically sign the current head so old ranges can be pruned and still verified.
await db.insertCheckpoint({ streamId, at: head.id, hash: head.hash, signature: sign(head.hash) });
Checkpoints bound verification cost and give an external anchor.
A checkpoint records the current head id and hash and signs the hash, giving an external anchor outside the mutable table. Bound verification to the range since the last checkpoint so cost stays proportional to recent activity rather than to total history.
Phase 4: Verification
Replay from the last checkpoint and recompute each hash.
if (sha256(canonicalOf(prev, event)) !== event.hash) return { valid: false, at: event.id };
Detecting the first mismatch localizes tampering.
Verification recomputes hashes and returns the id of the first record whose stored hash does not match. Naming the exact record turns a vague tampering warning into an investigation with a starting point.
Phase 5: Query API
Expose filtered reads without allowing updates or deletes.
app.get('/audit', limitAndFilter(handler)); // no PUT/DELETE routes
Audit readers need queries but never mutations.
The read API supports filters and limits but registers no update or delete routes. Omitting the mutation surface is a stronger guarantee than a permission check that a future commit could accidentally loosen.
Testing
Append a sequence, verify it passes, then mutate one stored field directly in the database and assert verification reports the exact broken record id.
Append a known sequence and assert verification passes, then update one field directly in the database and assert the verifier reports the exact id of the first broken record.
Test concurrency by appending from two connections and asserting the chain has no fork, and test checkpoint replay by verifying a stream after pruning all rows before a checkpoint.
Because canonicalization is the fragile part, add a golden-vector test that hashes a fixed event and pins the expected digest, so an accidental change to the serialization fails CI immediately.
Verify that the read API has no mutation routes by inspecting the router, since a test that only exercises behavior would miss a newly added delete route that happens to be unused in the suite.
Add a property-style test that appends a random batch and verifies it, which catches ordering mistakes a hand-written sequence might not. Random inputs tend to find the off-by-one that turns a linear chain into a fork.
pnpm vitest run
Deployment and operations
Store the chain in a database with restricted update privileges: the service account should be able to insert and select but not update. Ship checkpoints to an external, append-only sink.
Grant the service role insert and select but not update or delete on the events table, because the guarantee is only real if the code physically cannot rewrite history. Ship checkpoints to an append-only external sink so an attacker with database access still cannot rewrite the anchors.
Set a retention window per stream and archive verified ranges behind their checkpoints. Never prune rows that a checkpoint does not yet cover, or the chain becomes unverifiable across the gap.
Back the store up with point-in-time recovery and rehearse a restore, because an audit log whose restore procedure is untested is one incident away from being unusable exactly when it is needed.
Publish the verification procedure to auditors as a short runbook: which checkpoint to trust, how to replay from it, and what a mismatch means. A guarantee nobody can independently reproduce is worth far less than one they can.
Expose verification as a read-only endpoint protected by the same authorization as the audit body itself, because the ability to prove integrity is only useful to someone already entitled to see the record.
Security considerations
Derive the actor from the authenticated request, never from the body. Encrypt sensitive fields and restrict reads with the same rigor as writes; an audit log that leaks is also a security failure.
Assume the database is writable by an insider; the chain and the external checkpoints are the defenses, and they work only if the signing key lives outside the database. Keep that key in a KMS with an audit trail of its own.
Redact sensitive fields before canonicalization rather than after, so secrets never enter the hashed payload. An audit log is a high-value target precisely because it concentrates a record of activity in one place.
Enforce the no-mutation rule in the database grants, not only in the API. Defense in depth here means a compromised application cannot rewrite history even if every application check is bypassed.
Do not grant the verification endpoint the ability to read arbitrary payload columns; return only the ids and hashes needed for the check. A verifier that leaks the full audit body is a read primitive for exactly the data the log is meant to protect.
Pin the canonical encoding with a version tag stored beside each event, so a future encoding change can be detected and migrated explicitly rather than breaking verification silently for older records.
Observability
Track append latency, stream head age, and verification runs. Alert if the head does not advance during activity or if any verification run fails.
Track append rate, verification duration, and the count of verification failures, each as a counter or histogram. A single verification failure deserves a page, because it means the chain no longer matches itself.
Alert on checkpoint lag: if the head has moved far past the last checkpoint, a pruned range could become unverifiable. Also watch the size of the verify replay, since an unbounded replay is a denial-of-service vector.
Log the actor and stream for every verification attempt, so a spike against one stream is visible as an investigation rather than as anonymous load.
Watch append latency by stream, because a lock contention on one stream usually shows up as a rising p99 well before it becomes an error, and it points at a hot subject worth partitioning.
Troubleshooting
Verification always fails. The canonical serialization changed between append and verify; pin the field order and encoding in code.
Chain forks under load. Two appends read the same head; serialize per stream with a transaction or a lock.
When verification fails, compare the canonical serialization before assuming tampering; a code change to field order produces the same symptom and is far more likely than an insider edit. Then check whether a checkpoint covers the failing range.
If a checkpoint signature verifies but the chain does not, the tampering is inside the covered range; if the signature itself fails, the key or the head hash changed. That single distinction collapses most investigations to one question.
Limitations and next steps
- The chain is per stream; cross-stream ordering is not guaranteed.
- Key rotation for signing checkpoints is manual.
- The example stores payloads in plaintext columns.
The chain is per stream, so there is no cross-stream total order and the service does not claim to reconstruct an exact global sequence of events.
Checkpoint signing key rotation is manual and there is no automatic re-signing of historical heads, and payload columns are stored in plaintext, relying on field-level redaction rather than encryption at rest for individual values.
Verification is a full replay of the range since the last checkpoint, so a very long unchecked range is expensive. More frequent checkpoints trade write cost for verification cost, a knob the operator must tune to their retention policy.
There is no built-in notarization against an external time source, so the timestamp on an event is only as trustworthy as the database row it sits in. A public timestamping service is the upgrade when the time itself must be independently provable.