How we built a tamper-evident WORM audit log for AI agents using SHA-256 hash chains and PostgreSQL
Published on dev.to | Tags: ai, security, postgres, node
When your AI agents are making real decisions — sending emails, approving contracts, deleting records — "we have logs" is not the same as "we can prove what happened." This is the story of how we built a cryptographically tamper-evident audit log for AI Governor, and why the implementation details matter more than people think.
The problem with normal audit logs
Most audit logs have a critical flaw: they can be altered after the fact. If someone with database access modifies a row, deletes it, or even changes the timestamp, there's no automatic way to detect it. For enterprise AI agents executing high-stakes actions, this is a compliance nightmare.
We needed something stronger: a WORM (Write Once Read Many) log where any tampering — however subtle — is immediately detectable.
SHA-256 hash chaining: the core idea
The approach is borrowed from blockchain design, but stripped of all the unnecessary complexity.
Every audit row stores two hash fields:
-
prev_hash— the SHA-256 hash of the previous row -
row_hash— the SHA-256 hash of the current row's canonical fields + prev_hash
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
agent_id UUID,
verdict TEXT NOT NULL,
model TEXT,
cost_usd NUMERIC(10,6),
task JSONB,
stages JSONB,
-- WORM chain
prev_hash TEXT NOT NULL DEFAULT '',
row_hash TEXT NOT NULL
);
The row_hash is computed as:
row_hash = SHA256(id + org_id + created_at + verdict + model + cost_usd + ... + prev_hash)
If anyone edits any field in any row — or deletes a row and renumbers them — the chain breaks. Every subsequent row's prev_hash will no longer match the row_hash of its predecessor.
Why a database function, not application code
Here's where most implementations go wrong: they compute the hash in application code, then insert. This creates a race condition — two concurrent requests can both read the same "last row" and write the same prev_hash.
We solved this with a PostgreSQL stored function that holds a per-org advisory lock:
CREATE OR REPLACE FUNCTION insert_audit_row(
p_org_id UUID,
p_agent_id UUID,
p_verdict TEXT,
-- ... other params
) RETURNS audit_log AS $$
DECLARE
v_prev_hash TEXT;
v_row_hash TEXT;
v_new_row audit_log;
v_lock_id BIGINT;
BEGIN
-- Per-org advisory lock: prevents concurrent inserts from racing on the hash chain
v_lock_id := hashtext(p_org_id::text);
PERFORM pg_advisory_xact_lock(v_lock_id);
-- Get the hash of the last row for this org
SELECT row_hash INTO v_prev_hash
FROM audit_log
WHERE org_id = p_org_id
ORDER BY created_at DESC
LIMIT 1;
v_prev_hash := COALESCE(v_prev_hash, '');
-- Compute the new row_hash
v_row_hash := encode(
digest(
p_org_id::text || COALESCE(p_agent_id::text, '') ||
p_verdict || COALESCE(p_model, '') || v_prev_hash,
'sha256'
),
'hex'
);
-- Insert and return the new row
INSERT INTO audit_log (org_id, agent_id, verdict, prev_hash, row_hash, ...)
VALUES (p_org_id, p_agent_id, p_verdict, v_prev_hash, v_row_hash, ...)
RETURNING * INTO v_new_row;
RETURN v_new_row;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
pg_advisory_xact_lock gives us per-org serialisation without locking the whole table. Two requests from the same org queue at the lock; requests from different orgs run fully parallel.
Verifying the chain
Chain verification walks every row for an org in order and checks:
- The current
prev_hashmatches the previous row'srow_hash - The current
row_hashmatches a fresh computation of the canonical fields
// Gateway verification endpoint: GET /audit/verify
async function verifyChain(orgId) {
const rows = await db
.from('audit_log')
.select('id, created_at, org_id, agent_id, verdict, model, cost_usd, prev_hash, row_hash')
.eq('org_id', orgId)
.order('created_at', { ascending: true });
let prevHash = '';
for (const row of rows) {
// Check linkage
if (row.prev_hash !== prevHash) {
return { ok: false, first_broken_id: row.id, detail: 'Chain linkage broken' };
}
// Recompute and check hash
const expected = computeRowHash(row, prevHash);
if (expected !== row.row_hash) {
return { ok: false, first_broken_id: row.id, detail: 'Row hash mismatch — row was modified' };
}
prevHash = row.row_hash;
}
return { ok: true, rows_checked: rows.length, detail: 'Chain intact' };
}
Making it public and auth-free
The most useful property: the chain can be verified by anyone without an account. We expose a public endpoint:
GET https://api.aigovernor.app/v1/audit/public-verify?org_id=<uuid>
No authentication required. A regulator, auditor, or third-party compliance tool can verify an organisation's full chain independently, without trusting us. This is the governance proof layer — not just "we recorded it," but "anyone can verify we didn't alter it."
What this means in practice
Before we built this, enterprise customers asking about AI Act compliance had to trust our word that logs weren't altered. Now they can hand a verification URL to their auditor. The auditor runs it. The hash checks out. Done.
The audit log is available on every plan including free — because governance evidence isn't a premium feature, it's a basic requirement.
If you're building AI agents in production and need this kind of governance infrastructure without building it yourself, we've packaged all of this into AI Governor. One line of code to integrate — swap base_url and api_key. The full pipeline activates from your first call.
Links:
- AI Governor — try the free tier (500k tokens/month)
- Security page — architecture details
- Pricing — all plans
Tags to use when posting: #ai #security #typescript #devops #compliance #devtools #openai
Top comments (0)