You've seen this pattern. You might have written it yourself. I know I did.
CREATE TABLE audit_log (
id SERIAL PRIMARY KEY,
user_id TEXT NOT NULL,
action TEXT NOT NULL,
resource TEXT,
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT now()
);
Every time something important happens — a user signs a contract, an admin changes permissions, a payment goes through — you INSERT INTO audit_log. Job done. You have an audit trail.
Except you don't.
The problem nobody talks about
That audit_log table sits in the same database as everything else. Anyone with write access can do this:
-- Oops. Never happened.
UPDATE audit_log SET action = 'invoice.viewed' WHERE action = 'invoice.deleted';
-- Or just make it disappear entirely
DELETE FROM audit_log WHERE user_id = 'admin_42' AND action = 'permission.escalated';
No trace. No alert. No way to know it ever happened.
This isn't a theoretical attack. It's the default state of every audit log built on a regular database table. Your DBA can do it. A compromised admin account can do it. A SQL injection exploit can do it. And nobody will ever know.
"But we have backups"
Sure. But backups tell you what the data was, not whether it was tampered with between now and then. If someone edits a row on Monday and you check the backup on Friday, you're comparing against a backup that might already include the edit.
"We use triggers and permissions"
Better. But triggers can be disabled by superusers. ALTER TABLE ... DISABLE TRIGGER ALL is one command. And PostgreSQL SECURITY DEFINER functions can bypass row-level security.
The fundamental issue isn't about access control. It's about provability. Can you prove — cryptographically, not just organizationally — that a log entry hasn't been modified since it was written?
With a regular INSERT? No. You can't.
What auditors actually want
When a compliance auditor asks "show me the audit trail for this transaction," they're not asking for a database dump. They want to know:
- Completeness — Are all events present? Can you prove nothing was deleted?
- Integrity — Can you prove nothing was modified after the fact?
- Ordering — Can you prove the sequence of events hasn't been rearranged?
A plain audit_log table answers none of these questions with certainty.
SOC 2, HIPAA, PCI-DSS Requirement 10, GDPR Article 32 — they all expect or require audit records with demonstrable integrity. "We have a database table" doesn't cut it.
Hash chains: the fix
The solution has existed since the 1990s (Haber & Stornetta, the paper that later inspired Bitcoin's blockchain). The idea is simple:
Each log entry includes a cryptographic hash of itself and the previous entry.
Event 1: hash("payload_1" + GENESIS_HASH) → hash_1
Event 2: hash("payload_2" + hash_1) → hash_2
Event 3: hash("payload_3" + hash_2) → hash_3
Now if someone modifies Event 2, hash_2 changes. But Event 3 was computed using the original hash_2. The chain breaks. The tampering is immediately detectable.
You can't silently edit a single row without invalidating every subsequent hash in the chain.
What this looks like in practice
Here's a concrete example. You're logging invoice events in a SaaS app:
import { SealTrail } from "sealtrail";
const st = new SealTrail({ apiKey: process.env.SEALTRAIL_API_KEY });
// Log an event — hash chain is built automatically
const event = await st.events.log({
actor: "finance_user_42",
action: "invoice.approved",
resource: "inv_12345",
context: { amount: 5000, currency: "USD" }
});
console.log(event.hash); // "a1b2c3d4e5f6..."
console.log(event.chain.position); // 42
Each event gets a SHA-256 hash computed from the canonicalized event payload (actor, action, resource, context), the previous event's hash, and the event timestamp.
The hash and chain position are returned with every event. They're not just metadata — they're cryptographic proof.
Verification: the part that matters
Logging isn't enough. The whole point is that you — or an auditor, or an automated compliance check — can verify that nothing was touched:
const result = await st.events.verify("evt_abc123");
if (result.valid) {
console.log("Event integrity verified");
console.log("Chain intact:", result.chainIntact);
} else {
console.error("TAMPER DETECTED");
console.error("Expected:", result.computedHash);
console.error("Got:", result.eventHash);
}
Verification recomputes the hash from scratch. If the stored hash doesn't match the computed one, someone changed the data. If chainIntact is false, someone broke the link between events — meaning an event was inserted, deleted, or reordered.
This isn't trust. It's math.
The DIY trap
At this point you might be thinking: "I can build this myself. SHA-256, a previous_hash column, done."
I thought the same thing. Here's what I ran into:
Concurrent writes. Two events arrive at the same millisecond. Both read the same previous_hash. Both compute their hash against it. You now have a forked chain. Fix: atomic transactions with unique constraints on chain position. Retry on conflict.
Canonical serialization. JSON.stringify({ a: 1, b: 2 }) and JSON.stringify({ b: 2, a: 1 }) produce different strings, therefore different hashes. You need deterministic JSON canonicalization, or your verification breaks when key order varies.
Chain partitioning. One global chain becomes a bottleneck. You need per-resource or per-tenant chains. But then you need to manage chain creation, head tracking, and cross-chain references.
Pagination at scale. Offset-based pagination breaks when events are being inserted. You need cursor-based pagination with stable sort keys.
Retry logic. Rate limits, network failures, concurrent conflicts — your client needs exponential backoff with jitter, and it needs to handle 409 Conflict responses specifically for chain contention.
It's solvable. But it's a week of work to build, and a lifetime of maintenance to keep secure. And if you get the canonicalization wrong, your entire chain is silently invalid.
Stop building underpants
There's a term in engineering for components that every team rebuilds from scratch despite it not being their core business: underpants (as in, everyone needs them, nobody should be hand-stitching them).
Your audit trail is underpants. It needs to work. It needs to be tamper-proof. But it's not what your users are paying you for.
I built SealTrail because I needed this for my own SaaS products and got tired of reimplementing it. It's an API — npm install sealtrail, log events, verify integrity. Hash chains, cursor pagination, isolated chains per resource or tenant, and cryptographic verification are handled for you.
// Your entire audit trail integration
import { SealTrail } from "sealtrail";
const st = new SealTrail({ apiKey: process.env.SEALTRAIL_API_KEY });
// Log (events are chained per chain — default or custom)
await st.events.log({
actor: userId,
action: "document.signed",
resource: documentId,
context: { ip: request.ip },
chain: "documents" // optional — isolates chains per resource type
});
// Verify
const proof = await st.events.verify(eventId);
// proof.valid === true | false
Three lines to log. One line to verify. Hash chain built automatically.
TL;DR
INSERT INTO audit_log |
Hash chain audit trail | |
|---|---|---|
| Can admin silently edit? | Yes | No — hash changes, chain breaks |
| Can rows be deleted? | Yes | Detectable — position gaps |
| Can order be changed? | Yes | No — each hash includes previous |
| Cryptographic proof? | No | SHA-256 chain verification |
| Compliance-ready? | Questionable | Verifiable record |
If your audit log is a regular database table, it proves nothing. It's a convenience log, not an audit trail.
If that's fine for your use case, carry on. But if you're handling financial data, healthcare records, legal documents, or anything where "we can prove this wasn't tampered with" matters — you need hash chains.
I'm Sylvain, solo founder at Zero Loop Labs. I build developer tools. SealTrail is my latest — a tamper-proof audit trail API for developers. Try it free.
Top comments (0)