Skip to content
Slicekit
All posts
· Slicekit Team

Tamper-evident, not tamper-proof: where the audit line really is

A hash-chained audit log makes tampering detectable, not impossible. Here is the exact limit, and what Slicekit does to push past it.

“Tamper-proof” is one of those words you should distrust on sight, especially in a security pitch. It promises that the past cannot be rewritten, full stop, and almost nothing an application ships actually delivers that. Slicekit hash-chains its audit log, and a hash chain is a genuinely useful thing. But it is tamper-EVIDENT, not tamper-PROOF, and the gap between those two words is exactly where most audit-trail marketing quietly lies. This post draws that line on purpose: how the chain works, the attack it does not stop, and what Slicekit actually does about it.

How the hash chain works

Every security-relevant action emits one uniform AuditEvent. Each event is sequenced into a SHA-256 hash chain: it carries a Sequence, the PrevHash of the event before it, and its own Hash, computed over canonical JSON that includes that previous hash. Events flow through a sequential audit-events Wolverine queue, so the chain links one event at a time with no race to reorder them, and the three fields are set by the queue consumer rather than by the feature author.

The point of a chain is that each event seals the one before it. One event’s Hash becomes the next event’s PrevHash, so the whole log is stitched together in order.

Sequence 41
PrevHash
8f3a…
Hash
c1d9…
Sequence 42
PrevHash
c1d9…
Hash
5b07…

edited row: recomputed hash no longer matches

Sequence 43
PrevHash
5b07…
Hash
a2e4…
Each event seals the one before it, so altering Sequence 42 leaves its stored Hash stale and every later PrevHash dangling. You get detection-grade integrity from the log pipeline itself, with no separate immutable store to run.

The payoff is detection. Edit a field or delete a line and the recomputed hash no longer matches what was stored, while every downstream PrevHash is left dangling. The next verification pass fails on a chain mismatch and points at exactly where the history was disturbed. This is the property a plain audit_log table can never give you: with an ordinary table, a stray UPDATE or a quiet DELETE changes the past and leaves nothing behind. The chain turns a silent edit into a visible break.

The limit nobody markets

Here is the part the word “tamper-proof” papers over. A hash chain detects tampering only as long as the verifier holds a value the attacker could not also rewrite. Think about what an operator with write access to the store can actually do. They do not have to edit one event and leave the chain broken. They can edit the event they want to change, then recompute that event’s Hash, then recompute the next event’s PrevHash and Hash, and walk forward to the end of the log. When they finish, every link verifies. The history is different and the chain is internally consistent. Nothing is dangling, so nothing fails verification.

That is the whole catch: a hash chain by itself is only as trustworthy as the most recent hash you can independently vouch for. If the chain’s head, its latest hash, lives in the same store the attacker controls, they rewrite the head too and the recomputation is undetectable. To make recomputation detectable you have to anchor the head somewhere the attacker cannot reach: publish or witness it externally, or commit it to append-only or write-once (WORM) storage, so the rewritten log can be compared against a value that did not move. This is well-trodden ground. Crosby and Wallach’s work on efficient data structures for tamper-evident logging is built precisely around external auditors and published commitments, and the practitioner summaries on designing tamper-evident audit logs make the same point: hashing alone gives you detection, not non-repudiation.

So be precise about the claim. A hash chain gives you detection-grade integrity. It does not give you non-repudiation, because the party who can write the log can also rewrite its head. Anyone selling “tamper-proof” out of a hash chain and nothing else is selling you the first word and skipping the second.

What Slicekit actually does

Slicekit does two concrete things and is honest about where the third begins.

First, it gets the durable copy off the box. Audit events are not stored in their own Postgres table next to the data an operator is editing. They are rendered as structured Serilog log lines and exported over OTLP to Loki, the same pipeline the application logs already travel; only a small chain cursor lives in Postgres. That separation matters more than it looks. The recompute-the-chain attack assumes the attacker controls the store that holds the chain. Shipping events off-box means the operator editing the application database is not, by that act alone, editing the audit trail. They would need write access to a second system to rewrite history there too. It raises the bar without pretending to be a wall.

Second, retention rides that same pipeline. There is no app-side purge job to write, schedule, or get wrong, and no RetentionDays knob in the API to drift out of sync with reality. The trail expires where every other log expires, on Loki’s compactor and its policy. Reusing the log pipeline also means the trail inherits structured querying, the in-app admin audit log, the provisioned Grafana dashboard, and trace correlation, instead of needing a parallel storage stack.

Third, and this is the honest boundary: shipping off-box is not non-repudiation. For that you add an anchor the writer cannot rewrite. The auditing guide points at an S3 Object Lock exporter on the OTel collector for write-once regulatory storage, and periodically anchoring the chain head externally is the same Crosby-and-Wallach move applied here. Slicekit ships the detection-grade chain and the off-box pipeline. A buyer who needs true non-repudiation adds the anchor. Calling that out is the difference between a template you can trust and a brochure.

A worked example: audited impersonation

The case that exercises all of this is an admin acting as a customer. Done naively, the admin “becomes” the user and every subsequent action is attributed to the wrong person, so the trail confidently lies even though no byte was tampered with. Slicekit uses a layered JWT instead: the target user drives the session through sub, while the acting admin rides along in an RFC 8693 act claim that exists only for attribution and the stop gate.

sub = target user   // drives permissions and validation
act = admin user     // audit attribution + the stop gate

AuditService.EnrichActor reads the act claim and stamps OnBehalfOfUserId onto every event raised during the session, so a profile edit performed as the customer carries both ids: the customer in Actor.UserId, the admin in Actor.OnBehalfOfUserId. No emitter needs to know impersonation is happening. On top of that, two dedicated events bracket the window and sit on the same hash chain as everything else: ImpersonationStartedEvent (Admin.ImpersonationStarted), which requires a free-text reason before the session is issued, and ImpersonationEndedEvent (Admin.ImpersonationEnded). Start demands the reason up front, and the session is capped on a short rolling window.

Read the trail back and you see who really acted, why they said they were doing it, and, because the bracketing events are links in the chain, whether the record between start and stop is internally intact. That last word is doing honest work. The chain tells you the run was not silently edited in place; it does not, by itself, tell you the whole log was never recomputed by someone with write access to Loki. That is the anchor’s job, and naming the boundary is the point.

See the auditing guide for the event shape, actor pseudonymization, and the chain, and the impersonation guide for the start, refresh, and stop flow.