Skip to content

HMAC audit chain

Every audit row stores:

id, ts, actor_user_id, project_id, action, target_type, target_id, details,
row_hmac, prev_row_hmac

Where:

row_hmac = HMAC-SHA256(
Z4J_SECRET,
canonical_json({
id, ts, actor_user_id, project_id, action,
target_type, target_id, details,
prev_row_hmac
})
)

The audit chain uses Z4J_SECRET itself; there is no separate audit secret.

canonical_json is sorted-keys, no whitespace, UTF-8. prev_row_hmac is the row_hmac of the previous row in id order. Genesis row has prev_row_hmac = null.

  • Tamper-evident - change any field → row_hmac no longer verifies.
  • Insertion-evident - inserting a row in the middle breaks the next row’s prev_row_hmac link.
  • Deletion-evident - deleting a row breaks the chain at the gap.

Not prevented:

  • Append-only suffix replacement - someone with Z4J_SECRET and DB access can truncate trailing rows and re-sign. Mitigation: periodically export row_hmac externally (e.g. to an append-only S3 bucket or a second DB).
Terminal window
z4j audit verify

Walks the full log; returns (ok, rows_verified, first_broken_id). Runs on demand (CLI / API / dashboard button). For a periodic check, wrap the CLI invocation in cron or a Kubernetes CronJob — the brain does not loop the verification internally.

Rotating Z4J_SECRET would break verification of pre-rotation rows under a naive key change. z4j supports a dual-key window for exactly this case: set the previous value(s) in Z4J_PREVIOUS_SECRETS (comma-separated) so the verifier accepts rows signed under any of those keys while writes use the new Z4J_SECRET. Once every retained row is provably re-anchored (or aged out), drop the old key from Z4J_PREVIOUS_SECRETS.

If you would rather start fresh: export old rows with their HMACs (still verifiable using the old secret externally), rotate, begin a new chain.

CSV / NDJSON export includes row_hmac + prev_row_hmac. Downstream verification requires Z4J_SECRET (share it with the verifier out-of-band).

HMAC-SHA256 on a small canonical payload is microseconds; the chain does not meaningfully slow writes. Verification of a million rows takes ~10 seconds on modest hardware.