Audit integrity
What the hash chain proves, what it does not, and the three things that close the gap.
#What the chain is
Each audit event's hash covers its own content and the hash of the event
before it, keyed with VEEFORE_AUDIT_CHAIN_SECRET.
texth(n) = HMAC(secret, h(n-1) ‖ content(n))
Change the content of event n-1 and h(n-1) no longer matches, so event n's
recorded previous-hash is wrong, and so is every event after it. Delete an event
and the same thing happens.
What this proves
Integrity of the stored sequence. No single event can be altered or removed in isolation. Any tampering is detectable, and it is localisable — the verification names the first position where the recomputed hash diverges.
Localisation is the useful part. "Something was tampered with" is almost useless; "the chain diverges at event 418,221, written at 02:14 on 3 October" narrows an investigation to a window.
#What it does not prove
An attacker with database write access and the chain secret could rewrite it
If both are compromised, the whole chain can be recomputed consistently and the verification would pass.
The chain's job is to make quiet, surgical tampering impossible — altering one inconvenient event, deleting one action. That is the realistic threat. A full consistent rewrite requires two separate compromises and leaves other traces.
It also does not prove completeness against a world outside the database. If events were never written — because the audit pipeline was down — the chain over what was written is perfectly valid. Coverage gaps and chain breaks are different failures.
#The three things that close the gap
1. Keep the chain secret out of the database. Inject
VEEFORE_AUDIT_CHAIN_SECRET from a secret manager. A database compromise should
not hand over the key that makes the database's own integrity check meaningless.
2. Restrict database write access. The application role should be the only thing that writes. A human with ad-hoc write access to the audit tables is the threat this is defending against.
3. Ship events to an append-only sink you control separately. Object storage with versioning and object lock, or your SIEM. Then "we believe nothing was altered" becomes "two independent records agree", which is a much stronger statement.
Subscribe a webhook to the event types you care about, or export on a schedule. → Audit and export
#Verify on a schedule
bashveefore audit verify
Exits non-zero on a break and names the first divergent position.
httpPOST /v1/audit-events/verify-integrity
Requires audit:verify_integrity, held by SECURITY_ADMIN and AUDITOR.
A check you only run after an incident tells you nothing about when the problem started
Run it daily, from CI, with the result going somewhere a person reads. Then a failure has a date attached, and the date narrows the investigation.
An AUDITOR role is sufficient for a scheduled check — it holds audit:read,
audit:export and audit:verify_integrity and nothing that can change
anything.
#Coverage gaps
AUDITED_SERVICE_UNAVAILABLE during an outage means events were not written.
The chain over what was written stays valid; the gap is in coverage.
Audit failure is the most serious dependency failure
The other 503s mean work is not happening, which is recoverable — the work
waits. Audit failure means evidence is being lost right now, and it does not
come back when the service recovers.
Alert on veefore_audit_pipeline_errors_total with zero tolerance. Not a
threshold — any increase.
After an outage, record the window on an incident. "Audit coverage is incomplete between 02:14 and 02:48 UTC" is a usable statement. Discovering it six months later from a gap in the data is not.
#Export integrity
bashveefore audit export --format csv \
--reason "Quarterly SOC 2 evidence" --out audit-2026-q3.csv
Watermarked, and you get a content hash. An auditor recomputes it to prove the file they received is the file that was produced.
Send the hash through a different channel than the file
The hash proves the file is unaltered only if it did not travel with the file. Emailing both in one message proves nothing — whoever could alter one could alter the other.
Send the file however is convenient. Send the hash over a channel that is independently authenticated.
Taking an export is itself an audit event, requiring a reason. Removing evidence from the system is worth knowing happened.
#Retention
Changing it requires audit:configure_retention — step-up gated, writing a
CRITICAL audit event.
Shortening retention destroys evidence, which is precisely why it is treated as a high-impact operation. A request to "clean up old audit data" leaves a mark explaining who asked and why.
Export and archive first. The archive is watermarked and hashed, so it remains verifiable afterwards.
#What is in the record
For a protected action: the actor and its type, the resolved authority and delegation chain, every policy consulted with versions, every rule that matched with its per-condition trace, every risk signal with its weight and explanation, the approval and who decided it, the credential lease by reference, the downstream status, the outcome, and the correlation ids.
Never credential material. Never a private key. Masked parameter paths are
masked in audit too — a MASK decision redacts the value in the trail as well
as in the forwarded request, because redaction is applied on ingest.
#Rotating the chain secret
Breaks verification of the existing chain. Before rotating:
- Export and archive the full trail. The export is independently hashed.
- Record the rotation point, so a future verification knows where to start.
- Rotate.
- Verify forward from the rotation point.
A project, not a config change.
#Verifying this page
bashnpm run test:integration # chain construction and break detection
veefore audit verify # against a live deployment
The integration suite includes cases that tamper with a stored event and assert the verification detects it at the right position.