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.
- PrevHash
- 8f3a…
- Hash
- c1d9…
- PrevHash
- c1d9…
- Hash
- 5b07…
edited row: recomputed hash no longer matches
- PrevHash
- 5b07…
- Hash
- a2e4…
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.