All posts
8 min readsecurityauditpostgres

How we made our audit log tamper-evident

A per-tenant SHA-256 chain + advisory locks. Why we picked this design over append-only Postgres logical replication.

By Sai Mareedu

Audit logs are append-only by convention, but a privileged DB user can still DELETE rows or rewrite history. SOC 2 auditors want cryptographic integrity — and the typical answer is "ship logs to immutable storage." That's the right answer for the long term, but it introduces a network dependency on every mutation that has to be in the same transaction as the audit row.

The design

Every row carries:

- `prev_hash` — the row_hash of the previous row in this tenant's chain - `row_hash` — sha256(prev_hash || canonical_json(row))

To verify the chain you walk it from the genesis row and recompute. Tampering shows up as either a missing link or a hash mismatch.

Concurrency

Two concurrent mutations on the same tenant would race to claim the same prev_hash. We serialise on a per-tenant pg_advisory_xact_lock:

lockKey := "audit:" + tenantID
tx.Exec(ctx, "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", lockKey)

This gives us a tenant-scoped critical section without a global table lock — busy tenants only serialise against themselves.

What we didn't do

We considered logical-replication-to-Kafka and signing batches with a TPM. Both add operational surface area we didn't want at this stage. The chain runs entirely in Postgres; a future migration to a Merkle tree or external attestor is one column and a backfill away.