Building a Hash-Chained Audit Log in PostgreSQL
The exact task this guide solves is making the compliance audit trail tamper-evident inside the database, so that any retroactive edit to a scheduling override or a documented exception is detectable rather than silent. An inspector will demand to see why the engine believed a pairing was legal six months ago, and the answer is only trustworthy if the record cannot have been quietly altered since. A hash chain — where each entry commits to the SHA-256 digest of its predecessor — turns any edit into a break that propagates through every later digest. This page builds the append-only table, a trigger that computes and enforces the chain, and a verification walk. It implements the audit requirements from the system security and access boundaries section and records the verdicts produced by the duty time validation rule engines.
Prerequisites
- PostgreSQL 14+ with the
pgcryptoextension fordigest(). - A canonical serialisation — a deterministic JSON encoding of each record so the same payload always hashes identically.
- Append-only privileges — the application role may
INSERTbut notUPDATEorDELETEon the audit table. - An operator identity on every write, resolved from the role-based access model.
Step 1 — Model the append-only chain
Each entry stores its payload, its own digest, and the digest of the previous entry. The genesis entry commits to a fixed sentinel. Storing the previous hash explicitly, rather than recomputing it on read, lets the verification walk confirm the chain without reconstructing history.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE audit_log (
seq bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
written_utc timestamptz NOT NULL DEFAULT now(),
operator text NOT NULL,
action text NOT NULL,
payload jsonb NOT NULL,
prev_hash bytea NOT NULL,
entry_hash bytea NOT NULL
);
-- Revoke mutation; the application role may only append.
REVOKE UPDATE, DELETE ON audit_log FROM PUBLIC;
Verify: the table accepts INSERT from the application role but rejects UPDATE and DELETE, so the chain can only grow.
Step 2 — Compute the chain in a trigger
A BEFORE INSERT trigger reads the current chain tip, sets the new entry’s prev_hash to it, and computes entry_hash over the previous hash concatenated with a canonical serialisation of the payload. Doing this in the trigger means the application cannot forge a hash — the database owns the chain.
CREATE FUNCTION audit_chain() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
tip bytea;
BEGIN
SELECT entry_hash INTO tip FROM audit_log ORDER BY seq DESC LIMIT 1;
NEW.prev_hash := COALESCE(tip, digest('genesis', 'sha256'));
NEW.entry_hash := digest(
NEW.prev_hash
|| convert_to(
jsonb_build_object(
'operator', NEW.operator,
'action', NEW.action,
'payload', NEW.payload
)::text,
'UTF8'
),
'sha256'
);
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_audit_chain
BEFORE INSERT ON audit_log
FOR EACH ROW EXECUTE FUNCTION audit_chain();
Verify: inserting two entries yields a second whose prev_hash equals the first’s entry_hash; the genesis entry’s prev_hash equals digest('genesis', 'sha256').
Step 3 — Walk the chain to detect tampering
Verification re-computes each entry’s expected hash from its stored predecessor and payload and compares it to the stored entry_hash. The first row where they diverge is the point of tampering — and every row after it also fails, because the break propagates.
CREATE FUNCTION verify_audit_chain()
RETURNS TABLE (seq bigint, ok boolean) LANGUAGE sql STABLE AS $$
SELECT
a.seq,
a.entry_hash = digest(
a.prev_hash
|| convert_to(
jsonb_build_object('operator', a.operator, 'action', a.action, 'payload', a.payload)::text,
'UTF8'
),
'sha256'
)
AND a.prev_hash = COALESCE(
lag(a.entry_hash) OVER (ORDER BY a.seq),
digest('genesis', 'sha256')
) AS ok
FROM audit_log AS a
ORDER BY a.seq;
$$;
Verify: SELECT * FROM verify_audit_chain() WHERE NOT ok returns no rows for an untampered log; after any row’s payload is altered (by a superuser bypassing the revoke), that row and all later rows appear.
Failure modes and troubleshooting
- Non-deterministic serialisation. If the payload JSON serialises with unstable key order, the recomputed hash differs from the stored one and every entry fails verification. Remediation: canonicalise the JSON (sorted keys) before hashing, both on write and on verify.
- Application-computed hashes. Letting the application supply
entry_hashlets a compromised client forge the chain. Remediation: compute the hash in the trigger, inside the database trust boundary. - Allowing updates. A chain is only tamper-evident if mutation is blocked for normal roles; leaving
UPDATEgranted lets an edit slip through undetected between verification runs. Remediation: revokeUPDATE/DELETEand run the walk on a schedule. - Genesis mismatch. If the verify function uses a different genesis sentinel than the trigger, the first entry always fails. Remediation: share one genesis constant between insert and verify.
- Timezone in the payload. A
written_utcrendered in local time hashes differently across a DST change. Remediation: keep all timestamps in the payload as UTC ISO-8601.
Frequently Asked Questions
Does a hash chain prevent tampering?
No — it makes tampering detectable, not impossible. A sufficiently privileged actor can still edit a row, but doing so breaks the chain from that row onward, and the verification walk surfaces exactly where. Combined with append-only privileges and off-site digest anchoring, that is what auditors accept as tamper-evidence.
Why compute the hash in a trigger instead of the application?
Because the chain must be owned by the trust boundary that stores it. If the application computes the hash, a compromised or buggy client can write a self-consistent but forged entry. The trigger guarantees every entry is chained by the database itself.
How often should the chain be verified?
On a schedule — commonly nightly — and on demand before any audit export. The walk is a single pass over the table, so it is cheap enough to run frequently, and frequent runs narrow the window in which undetected tampering could exist.
Can the log be anchored beyond the database?
Yes. Periodically publishing the current chain tip to an append-only external store or a timestamping authority anchors the whole history: even a full-database rewrite cannot reproduce a tip that was already published elsewhere.
Related
- System Security & Access Boundaries — the parent section and audit requirements.
- Implementing Role-Based Access for Flight Ops Data — the operator identities recorded on every entry.
- Regulatory Reference Tables — reference-value changes that are themselves audited events.
- Duty Time Validation & Rule Engines — the verdicts the log records.
Back to System Security & Access Boundaries.