Audit

How a Hash-Chained Audit Log Actually Stops Tampering

Akshay Sarode 7 min read
Direct answer

Storing something in a database only proves you have a database: any row can be edited or deleted after the fact, and the row that replaces it looks perfectly normal. A hash-chained log makes each row's hash depend on the row that came before it, so editing any past row changes a value that every later row was computed against, and the mismatch cascades forward until a verifier catches it at the exact row where the tampering happened. It's a simple, decades-old cryptographic pattern, not a blockchain, and Ujex uses it for every privileged write an agent makes.

Say an AI agent in your production system approved a $40,000 wire transfer at 2:14pm on a Tuesday. Six months later, during a dispute, someone asks whether that approval record can be trusted. If the honest answer is "it's a row in Postgres," you haven't actually answered the question. A database row can be updated by anyone with write access, on purpose or by a bad migration, and afterward it looks exactly like it always did. There's no scar tissue. Tamper-evidence is a different property than storage, worth being precise about before anyone claims to have it.

"We store it in a database" is not a tamper-evident claim

Most systems that call themselves "audited" mean something modest: there's a table, it has a timestamp column, and someone can query it later. That's useful for debugging and dashboards, but it doesn't answer the question a real audit needs answered: can I trust that this row reads today the same way it read the day it was written? A plain database gives no way to check. An UPDATE against an audit_log table is exactly as easy to run as an UPDATE against any other table, and once it commits, the old value is gone unless something outside the row itself remembers what it used to be.

Tamper-evident means the data structure carries proof of its own history. Not "we have backups" (backups get restored from, and can be edited before the next backup runs), not "we log access to the table" (access logs are just more rows, with the same problem one level up), but something you can recompute from the data itself and compare against what's stored. That's what a hash chain gives you.

The mechanism: each row's hash covers the row before it

The idea fits in one line: hash(row N) = H(hash(row N-1), payload of row N). Every row stores its own payload (who did what, to what, when) and a hash, and that hash is a fingerprint of the row's payload combined with the previous row's hash, not just the payload alone. Row 1 has no predecessor, so it chains from a fixed starting value. Row 2's hash comes from row 1's hash plus row 2's payload. Row 3's hash comes from row 2's hash plus row 3's payload, and so on for as many rows as the agent has written.

That one design choice is what makes the whole thing useful. Edit row 3's payload after the fact and recompute its hash to match (you'd have to, since the stored hash is a real function of the payload), and the value you get differs from the one originally stored. That alone might not matter if nothing downstream depended on it. But row 4's hash was computed using row 3's original hash as an input, so row 4's stored hash no longer matches a fresh recomputation of hash(row 3's new hash, row 4's payload). Row 4 is now inconsistent too, even though nobody touched its payload. Row 5 was chained from row 4's original hash, so the same thing happens there. Edit one row in the middle and every row after it silently stops matching a fresh recomputation, all the way to the end.

A verifier's job is boring on purpose: walk the chain top to bottom, recompute each hash from the stored payload and the previous row's stored hash, and compare it to what's saved. The moment recomputation and storage disagree, you've found the earliest point of tampering, not "somewhere in the last six months," the exact row.

1 2 3 4 5 OK OK EDITED BROKEN BROKEN break
Row 3 gets edited after the fact. Its hash changes, which breaks the link to row 4, and the mismatch cascades through every row after it.

See it break: tamper with row 3

The best way to understand a hash chain is to break one. Below is a simulation of five rows from an agent's audit log, the kind of entries Ujex appends for a real action like sending an email, granting an approval, or writing to agent memory. Click the edit button on row 3 and watch what happens to the rows after it.

Try it: tamper with row 3
RowPayloadHashStatus
1 Agent sent email 4f0a91c2 OK
2 Approval granted b7e19dfa OK
3 Memory written c92a05f3 OK
4 Quota checked d105b6e9 OK
5 Agent spawned e88f3c22 OK

Simulation only. These hashes are short fake strings for illustration, not real cryptographic output.

Notice that rows 1 and 2 stay green. They come before the edit, so nothing about them changed and nothing about them needed to be recomputed. Rows 3 through 5 flip because row 3's hash no longer matches what a verifier gets by recomputing it, and that mismatch is baked into every hash computed after row 3 was written. In a real Ujex chain this comparison runs automatically; nobody has to eyeball rows to notice the drift.

Why hash-chaining beats signing each row on its own

An obvious alternative is to sign each row individually with a private key and skip the chaining entirely. Signing solves a real problem: it proves a given row hasn't been altered since the moment it was signed, and it proves who, or what service, produced it. Plenty of systems stop there and call it done.

The gap is that a signature only ever talks about the row it's attached to, not the row's position in a sequence. If someone with database access deletes row 7 entirely, every remaining signed row still verifies fine on its own, because nothing about rows 1 through 6 or 8 through 20 changed. The gap where row 7 used to be isn't visible to a check that only asks "does this row's signature match this row's content." The same problem shows up in reverse: insert a new, validly signed row between what used to be row 7 and row 8, and every individual signature still checks out, because the forged row was signed correctly, it just doesn't belong there.

A hash chain closes that gap because each row's hash is a function of its neighbor, not just its own content. Delete row 7 and row 8's stored "previous hash" now points to a row that no longer precedes it; recomputing from what's actually left produces a different value than what's stored in row 8. Insert a row and the same thing happens, because whatever comes after was chained against the old sequence, not the new one. Signing catches "this row's content changed." Chaining catches that, plus insertion and deletion, because it encodes position, not just content.

This is not a blockchain

It's worth saying plainly, because the word gets thrown around loosely: a hash chain is not a blockchain. There's no distributed network of nodes reaching consensus on a canonical history, no mining, no proof-of-work, no token, no peers voting on validity. It's a linked list where each node carries a hash of its predecessor instead of just a pointer, an old idea in cryptography, well understood, boring in the best sense: there's no novel failure mode to reason about, just "did the recomputed hash match the stored one, yes or no."

The distinction matters because "blockchain" implies a specific answer to a specific problem: how mutually distrusting parties agree on a single shared history with no central authority. Ujex's audit chain doesn't try to solve that problem, because it doesn't need to. The problem it solves is narrower: prove that a log, once written, hasn't been quietly rewritten. A hash chain is the right-sized tool for that job.

How often the chain actually gets checked

A hash chain nobody verifies is just an audit log with extra math in it. It only earns the "tamper-evident" label if something actually walks it and flags a mismatch when one exists. Ujex runs an hourly verifyChain job that does exactly that: recompute, compare, flag. It's incremental and resumable rather than a one-time check against a fixed cutoff: it picks up roughly where the last run left off and keeps working through the whole chain over successive runs, so a chain that's grown for a year still gets fully covered over time, not just the recent slice.

1 hr
verifyChain and anchor cadence
2
separate chains: agent actions vs. admin and synthetic traffic
Apache-2.0
verifier license, TypeScript and Python

There's a second layer on top of verification, because verification alone has a blind spot: if someone with full admin access to the underlying Firebase project rewrote the entire chain consistently (edited a row and recomputed every hash after it to match), a verifier running inside that same project would see a self-consistent chain and report everything fine. To close that gap, an hourly anchor job publishes the chain's current root hash to an external, git-tracked log outside the hosting project, a rewrite an internal actor can't quietly fix since it would also mean rewriting a git history under different control. Compare a freshly recomputed root against an old anchored one and a rewrite becomes visible even when it's internally self-consistent.

One more detail: admin actions and synthetic monitoring traffic write to separate chains, not the main one. That keeps a load test or a support engineer's manual intervention out of the same sequence as real agent actions, both for signal (no monitoring noise between two real events) and because of the position-dependent nature of chaining itself, where an unrelated admin write would otherwise shift what "the row after this one" means for the agent audit history.

GDPR, erasure, and verifying the chain yourself

An append-only log that's supposed to last forever runs headfirst into any regulation that gives people a right to have their data deleted. If chain integrity depended on every payload staying exactly as written forever, honoring a GDPR erasure request would look like it should break the chain by definition.

It doesn't, because of how the chaining is built. Each row's hash depends on a digest of the payload taken once, at write time, not on a promise that the raw payload sits there forever. Redacting a payload later, replacing "agent sent email to jane@example.com with body: ..." with a tombstone, doesn't touch the hash already derived from it. The chain link that matters for verification survives the redaction, because verification never rechecked the raw payload against the outside world; it checked that the stored hash for that row is still consistent with its neighbors. A row's sensitive content can be lawfully erased and the row's hash, and everything chained after it, still verifies.

The other trust question worth asking is simpler: why believe any of this without taking Ujex's word for it? You shouldn't have to. The hash-chaining code itself is open source under Apache-2.0, in two languages: @ujexdev/audit-chain for TypeScript and ujex-audit-chain for Python. Export a chain and run the same verification logic Ujex runs, on your own machine, against your own copy of the data, and get an independent yes or no. That's stronger than a dashboard telling you everything's fine, because you're not trusting a claim, you're running the check.

FAQ

Is a hash-chained audit log the same thing as a blockchain?

No. There's no distributed network of nodes, no consensus mechanism, no mining, and no peers agreeing on a canonical history. It's a simpler, older structure: a linked list where each entry's hash is computed from the entry before it, enough to detect tampering in a single, centrally written log, which is a narrower problem than the one blockchains solve.

How often is the chain actually verified?

An hourly job walks it. Verification is incremental and resumable rather than a one-time check against a fixed cutoff, so each run picks up roughly where the previous one stopped and the entire history gets covered over successive runs, not just the most recent entries.

Is this compatible with GDPR if the log is supposed to be append-only forever?

Yes, through chain-safe redaction. Each row's hash comes from a digest of its payload taken at write time, not a requirement that the raw payload persist forever. A payload can be erased to satisfy a deletion request while the row's hash, and every hash chained after it, still verifies correctly.

Can I verify an exported chain myself, without trusting Ujex's servers?

Yes. The verifier is open source under Apache-2.0, available as @ujexdev/audit-chain in TypeScript and ujex-audit-chain in Python. Export a chain and run the same recomputation-and-compare logic independently, checking the math yourself rather than trusting a dashboard.