Build a Tamper-Proof Audit Trail
Tamper-Proof Audit Trail — a complete walkthrough of audit logging, covering event appender, hash chain, merkle checkpoint, and testing.
Project goal
Implement audit logging that solves appending events so that any later tampering is detectable through a chained hash, with each component independently testable.
Final features
- Event appender
- Hash chain
- Merkle checkpoint
- Verification walker
- Export signer
Architecture
The system is organized around event appender, hash chain, merkle checkpoint, and verification walker ties them together. Data flows as a sequence of audit events where each entry hashes over the previous entry, with periodic Merkle roots for compact verification.
Prerequisites
- Node.js 20 LTS or later
- Familiarity with audit logging fundamentals
- A local environment to run the test suite
Dependencies
- fastify ^5.0.0 — Append API
- blake3 ^0.2.0 — Chained hashing
- level ^8.0.0 — Append-only store
Directory structure
audit_trail_tamperproof/
├── src/index.ts
├── src/event-appender.ts
├── src/hash-chain.ts
├── tests/audit_trail_tamperproof.test.ts
└── package.json
Phases
1. Event appender
Build the event appender that anchors audit logging and defines the core data shape.
// Event appender: audit logging core
export function eventappender(input: EventappenderInput): EventappenderResult {
// Build the event appender that anchors audit logging and defines the core data shape.
return processEventappender(input);
}
Expected behavior: After this phase the event appender works in isolation and is covered by a unit test.
2. Hash chain
Add the hash chain so audit logging can appending events so that correctly.
// Hash chain — handles audit logging Add the hash chain so
export class Hashchain {
constructor(private opts: HashchainOptions) {}
async run(ctx: Context): Promise<HashchainOutput> {
return this.execute(ctx);
}
}
Expected behavior: After this phase the hash chain works in isolation and is covered by a unit test.
3. Merkle checkpoint
Wire the merkle checkpoint to handle a tampered early entry without losing data.
// Merkle checkpoint: audit logging stage
export const merklecheckpoint = (next: Next) => async (req: Request) => {
const out = await transform(req);
return next(out);
};
Expected behavior: After this phase the merkle checkpoint works in isolation and is covered by a unit test.
4. Verification walker
Integrate the verification walker to make audit logging observable and testable end to end.
// Verification walker for audit logging
export async function verificationwalker(stream: AsyncIterable<Chunk>): Promise<Aggregate> {
let acc = init();
for await (const c of stream) acc = merge(acc, c);
return acc;
}
Expected behavior: After this phase the verification walker works in isolation and is covered by a unit test.
5. Export signer
Finalize the export signer so the audit logging system degrades gracefully under overload.
// Export signer: audit logging finalization
export function exportsigner(state: State): Result {
validate(state);
return finalize(state);
}
Expected behavior: After this phase the export signer works in isolation and is covered by a unit test.
Testing
Each phase has a focused test: event appender, hash chain, merkle checkpoint are exercised in isolation, then an integration test drives the full audit logging pipeline and asserts the a tampered early entry case is handled.
Troubleshooting
A tampered early entry that changes every subsequent hash and is caught by comparing against an external checkpoint
Reproduce with a minimal fixture, then add a guard test so the audit logging system rejects the condition loudly.
Performance regresses under load
Profile with the sampling analyzer, locate the hot path, and add a targeted benchmark to lock the regression.
Deployment
Run two instances behind a load balancer, share state in Redis, and set resource limits so overload surfaces as backpressure rather than failure.
Known limitations
- Single-region; multi-region replication is out of scope.
- The event appender is in-process and not horizontally shared by default.
- No auth layer; assume deployment behind an edge that enforces identity.
Project overview
This project builds a audit logging system whose reason for existing is appending events so that any later tampering is detectable through a chained hash. The temptation in audit logging is to bolt the interesting part onto a working prototype and discover the hard cases in production; this build inverts that order and treats a tampered early entry that changes every subsequent hash and is caught by comparing against an external checkpoint as the first thing designed, not the last.
You will implement Event appender, Hash chain, Merkle checkpoint, Verification walker, and Export signer, each as a module with one responsibility and a test that proves it in isolation. The audit logging pipeline is then the wiring that composes them, and the integration test is where the composition is shown to survive a tampered early entry that changes every subsequent hash and is caught by comparing against an external checkpoint.
The walkthrough is written for a developer who has used a audit logging system but has not had to make one honest. It favors explicit data shapes over clever abstractions, and it names the failure each design choice prevents rather than presenting the choice as a style preference.
Each phase ends with something observable — a event appender that returns the right shape, a hash chain that rejects bad input, an integration test that goes green — so you can stop at any point and still have a audit logging system that works for the cases covered so far.
What you will build
The finished audit logging system exposes five capabilities, each with a focused test:
- Event appender
- Hash chain
- Merkle checkpoint
- Verification walker
- Export signer
Event appender is the foundation that every later stage assumes; Hash chain is where audit logging correctness lives; Merkle checkpoint handles a tampered early entry that changes every subsequent hash and is caught by comparing against an external checkpoint; Verification walker makes the system observable; and Export signer is the graceful-degradation layer that keeps overload from becoming corruption.
The build keeps the audit logging logic in ordinary modules you can read in one sitting, and pulls in dependencies only for the parts that are genuinely tedious to hand-roll. That keeps the diff between the tutorial and a real codebase small, and keeps the failure modes visible in code rather than hidden behind a framework.
Architecture and data model
The system is organized around Event appender, Hash chain, and Merkle checkpoint, with Verification walker tying them together. Data flows as a sequence of audit events where each entry hashes over the previous entry, with periodic Merkle roots for compact verification.
Each component is a module with a pure interface where practical: the same input produces the same output, and side effects are pushed to the edges. That discipline is what makes the phases independently testable, because a test for Merkle checkpoint does not need to stand up Event appender and Hash chain to exercise the interesting branch.
The data model is deliberately explicit. A sequence of audit events where each entry hashes over the previous entry, with periodic Merkle roots for compact verification Keeping the shape written down — in a schema, a type, or a fixture — means a change to the shape is a visible diff rather than a silent runtime shift, and it means the integration test can assert against a concrete shape instead of guessing.
Configuration and code are kept apart: the configuration says what audit logging should do, the modules say how. A reviewer can change a threshold or a policy without reading the implementation, which keeps the risky changes small and lets the diff stand as the review.
The failure path through Merkle checkpoint is designed before the happy path is finished, because a tampered early entry that changes every subsequent hash and is caught by comparing against an external checkpoint is the case that distinguishes a audit logging system from a demo. Every component declares what it does when its inputs are wrong, and the integration test asserts each of those declarations.
Prerequisites and dependencies
Have these ready before you install anything, so the audit logging setup is not blocked by a missing tool:
- Node.js 20 LTS or later
- Familiarity with audit logging fundamentals
- A local environment to run the test suite
The runtime dependencies are chosen to keep the audit logging datapath legible:
fastify(^5.0.0) — Append APIblake3(^0.2.0) — Chained hashinglevel(^8.0.0) — Append-only store
# setup for audit-trail-tamperproof
pnpm init
pnpm add fastify blake3 level
pnpm add -D typescript tsx vitest
Versions are pinned to a major range so the audit logging install is reproducible; commit the lockfile. The event appender reads its operational parameters from the environment and validates them at startup, so a missing value fails the boot rather than the first audit logging request.
Directory structure
Keep each audit logging component in its own file so it can be tested without starting the others:
audit_trail_tamperproof/
├── src/index.ts
├── src/event-appender.ts
├── src/hash-chain.ts
├── tests/audit_trail_tamperproof.test.ts
└── package.json
Phase 1: Event appender
Build the event appender that anchors audit logging and defines the core data shape.
// Event appender: audit logging core
export function eventappender(input: EventappenderInput): EventappenderResult {
// Build the event appender that anchors audit logging and defines the core data shape.
return processEventappender(input);
}
After this phase the event appender works in isolation and is covered by a unit test. The event appender defines the data shape that every later stage assumes, so getting it right first means the later phases compile against a stable contract.
Treat the event appender as the single source of truth for the audit logging data shape. Validate inputs at the boundary and reject early, so a malformed value never reaches Hash chain or Merkle checkpoint where its effect would be harder to localize.
Phase 2: Hash chain
Add the hash chain so audit logging can appending events so that correctly.
// Hash chain — handles audit logging Add the hash chain so
export class Hashchain {
constructor(private opts: HashchainOptions) {}
async run(ctx: Context): Promise<HashchainOutput> {
return this.execute(ctx);
}
}
After this phase the hash chain works in isolation and is covered by a unit test. This is where audit logging correctness lives: the hash chain is what makes appending events so that any later tampering is detectable through a chained hash true, and its test is the proof.
Keep the hash chain pure where you can. A function that takes an input and returns an output is trivial to test and impossible to mock wrong; a class with hidden state is the opposite. Push side effects to the caller and keep the hash chain about the transformation.
Phase 3: Merkle checkpoint
Wire the merkle checkpoint to handle a tampered early entry without losing data.
// Merkle checkpoint: audit logging stage
export const merklecheckpoint = (next: Next) => async (req: Request) => {
const out = await transform(req);
return next(out);
};
After this phase the merkle checkpoint works in isolation and is covered by a unit test. The merkle checkpoint is the difference between a audit logging system that works on the happy path and one that survives a tampered early entry that changes every subsequent hash and is caught by comparing against an external checkpoint.
Design the failure before the success. Decide what merkle checkpoint does when a tampered early entry that changes every subsequent hash and is caught by comparing against an external checkpoint: reject, retry, shed, or degrade — and write that test first. A audit logging system whose failure behavior is unspecified will pick one at random under pressure, and it is usually the wrong one.
Phase 4: Verification walker
Integrate the verification walker to make audit logging observable and testable end to end.
// Verification walker for audit logging
export async function verificationwalker(stream: AsyncIterable<Chunk>): Promise<Aggregate> {
let acc = init();
for await (const c of stream) acc = merge(acc, c);
return acc;
}
After this phase the verification walker works in isolation and is covered by a unit test. Observability is not a finishing touch; it is how you know the audit logging system is doing what it claims in production.
Emit one structured event per meaningful action, with enough context to localize a failure without a second round-trip. The verification walker is what turns “something is slow” into “the hash chain is slow for inputs matching this shape,” which is the difference between an alert and an investigation.
Phase 5: Export signer
Finalize the export signer so the audit logging system degrades gracefully under overload.
// Export signer: audit logging finalization
export function exportsigner(state: State): Result {
validate(state);
return finalize(state);
}
After this phase the export signer works in isolation and is covered by a unit test. The export signer is what keeps overload from becoming corruption: when the audit logging system is asked for more than it can do, it degrades in a defined way instead of failing in an undefined one.
Pick a degradation strategy for export signer — shed, queue, or backpressure — and make it explicit in code and in a test. Leaving the behavior to chance is how a audit logging traffic spike becomes a data loss incident.
Testing
Each phase has a focused test: event appender, hash chain, and merkle checkpoint are exercised in isolation, then an integration test drives the full audit logging pipeline and asserts the a tampered early entry case is handled.
Start with unit tests that pin each component’s contract: the event appender returns the documented shape, the hash chain transforms correctly, and the merkle checkpoint fails in the documented way. These tests are fast and they localize regressions to the component that broke.
Then add an integration test that composes the components and drives a realistic audit logging scenario end to end. Assert the happy path first, then deliberately trigger a tampered early entry that changes every subsequent hash and is caught by comparing against an external checkpoint and assert the system degrades rather than corrupts. Because the components are pure where practical, the integration test stays deterministic rather than flaky.
A audit logging suite must also prove the negative: bad input is rejected at the boundary, overload is shed rather than queued indefinitely, and a partial failure does not leave the system in an inconsistent state. These are the tests that catch the regressions a happy-path suite never would.
The entire audit logging suite runs with no external service; every dependency is either in-process or stubbed, so every case is reproducible in CI. That is what lets the merkle checkpoint failure-mode assertions live as tests rather than as a manual checklist that drifts out of date.
# run the audit logging test suite
pnpm vitest run
Deployment and operations
Run at least two audit logging instances behind a load balancer and share state in Redis. Set resource limits so audit logging overload surfaces as backpressure rather than failure.
Run at least two stateless instances behind a load balancer, and move any shared state into a shared store so the audit logging behavior is global rather than per instance. Size limits so the system sheds load before it runs out of memory, because a audit logging system that OOMs under load fails in a way that is hard to reason about.
Drain on deploy: stop accepting new work, let in-flight audit logging operations finish, then exit. Because the system holds no state that cannot be reconstructed, a rollout is a matter of cycling instances one at a time while watching the error rate.
When a dependency is unavailable, decide explicitly whether the audit logging system fails closed or degrades. Leaving that choice to chance is how a dependency outage becomes a silent data loss, so make the choice visible in code and in a test.
Security considerations
Validate all audit logging inputs at the boundary, reject oversized payloads before buffering them, and keep secrets in the environment rather than the repository.
The audit logging system is a choke point, so it is the right place to reject malformed input before it reaches hash chain or merkle checkpoint. Validate the shape and the size at the edge, and let the inner components assume what they receive is well-formed.
Never log secrets or full payloads; log enough to localize a failure without recording data that should not be on disk. Treat any external call as untrusted and validate its response before using it, because a audit logging system that trusts its inputs blindly is one malformed response away from corruption.
Keep dependencies pinned and run an audit in CI. A transitive dependency with a known vulnerability is still a vulnerability in your audit logging system, and the lockfile is what makes the audit meaningful.
Observability
Emit one structured event per audit logging operation with the inputs’ shape, the outcome, and a correlation id. Track error rate and latency per component and alert on a step change rather than a single spike.
The correlation id is the thread that ties a user-visible symptom to the audit logging decision and to the component log entry for the same operation. Without it, an investigation starts from a timestamp and a guess.
Watch the merkle checkpoint failure rate separately from the overall error rate. A jump in merkle checkpoint failures usually means a tampered early entry that changes every subsequent hash and is caught by comparing against an external checkpoint, not a bug in hash chain, and the two have different owners and different fixes.
Chart the share of audit logging operations that shed, queue, or succeed. A change in the mix points at the cause faster than an aggregate latency can, because each class has a different root.
Troubleshooting
A tampered early entry that changes every subsequent hash and is caught by comparing against an external checkpoint. Reproduce with a minimal fixture, then add a guard test so the audit logging system rejects the condition loudly.
Performance regresses under audit logging load. Profile with the sampling analyzer, locate the hot path in hash chain, and add a targeted benchmark to lock the regression.
Keep the audit logging dashboards separate from dependency dashboards. When an operation fails, the correlation id lets you decide in seconds whether the fault is event appender, hash chain, or merkle checkpoint, instead of guessing from a timeout.
Limitations and next steps
- Single-region; multi-region replication is out of scope.
- The event appender is in-process and not horizontally shared by default.
- No auth layer; assume deployment behind an edge that enforces identity.
The audit logging system is single-region by design; extending to multi-region means making event appender shareable across regions, which is a real change to the consistency model rather than a config flag. The merkle checkpoint is in-process, which is correct for one instance but must move to a shared store before scaling out.
There is no authentication or authorization layer; the system assumes it is deployed behind an edge that enforces identity. Adding one is straightforward but out of scope for this build, and doing it here would distract from the audit logging correctness that is the point.
The natural next step, once the audit logging pipeline is trusted in production, is to add a short-TTL cache for idempotent reads and a richer verification walker that exports histograms rather than counters. Both are additive and neither changes the contracts this build establishes.