Why every audit log entry carries the hash of the one before it
OpsNexus chains its audit log with SHA-256 so that edited or deleted entries show up. Here is how the chain is built, what it protects against, what it doesn't, and how to check it yourself.
An audit log is only useful if you can trust it. When something goes wrong, the first questions are "who changed this?" and "who revealed that secret?". If the answer comes from a table that anyone with database access can quietly edit, the answer is weak.
OpsNexus records every sign-in, change, deployment and secret reveal in its audit log. Each entry also stores two hashes: its own, and the one of the entry before it. This post explains why, how the chain is computed, and how you can verify it without trusting OpsNexus.
What gets recorded
Every route or background task that changes something calls one function, audit.record(). An entry holds:
- the actor (user ID and a display label) and their IP address;
- the action, such as
pipeline.trigger,auth.api_key.createorsecret.reveal; - a category (Sign-in, Access, Secrets, Deploy, Cloud…);
- the resource type, ID and label;
- the result:
success,deniedorfailed; - a
detailsobject with extra context.
Before anything is stored, details is scrubbed. Any key that contains password, token, secret, private_key, client_secret or value is replaced with ***, at every level of nesting. A secret's name or path can appear in the log; its value never does.
The application only ever inserts audit rows. No route updates or deletes them.
The chain
When OpsNexus writes an entry, it first reads the hash of the newest entry for the same organization. That becomes prev_hash. Then it hashes a fixed list of fields together with prev_hash:
prev = newest_hash_for_this_organization # None for the very first entry
payload = json.dumps(
[str(org_id), str(actor_id), action, resource_type, resource_id, result, details, prev],
sort_keys=True, default=str,
)
entry.prev_hash = prev
entry.hash = hashlib.sha256(payload.encode()).hexdigest()Every organization has its own chain, starting from an entry with no prev_hash.
Because each hash includes the previous hash, the entries form a chain. Change one field of an old entry and its hash no longer matches what you recompute. Recompute and store a new hash to hide that, and the next entry's prev_hash now points at a hash that no longer exists. To hide one edit, you would have to rewrite every entry that came after it.
What the chain covers
Look closely at the payload. It covers the organization, actor ID, action, resource type and ID, result and details. Some columns are not hashed: the timestamp, the display labels, the category and the IP address.
That is a deliberate trade. Labels are for people reading the log, and the IDs next to them are what identify things. Keeping the payload small and stable also means new columns can be added later without breaking verification of old rows. When OpsNexus added projects, it gave each audit entry a project column. The column isn't hashed, so every entry written before that change still verifies.
What it protects against, and what it doesn't
A hash chain is tamper evidence, not tamper proofing. Be clear about what that means:
- It catches edits and deletions in the middle. Someone who changes an action, removes a
deniedentry or editsdetailsbreaks the chain at that point. - It does not stop someone with full write access to the database from rewriting the whole chain from the point of the edit to the end. The math works for them as well.
The defence against the second case is to keep a copy of recent hashes somewhere the database admin can't reach. Every entry's hash is in the CSV export, so a scheduled export to your SIEM or to write-once storage gives you anchors. If the hash of an entry from last week in your archive no longer appears in the database, someone rewrote history.
curl -s "https://opsnexus.example.com/api/audit-logs/export.csv?since=2026-11-02T00:00:00Z" \
-H "X-API-Key: $ONX_ACCESS_KEY.$ONX_SECRET_KEY" -o audit-$(date +%F).csvThe export needs read access to the Audit module. The UI shows each entry's hash in the entry's detail panel as well.
Verifying the chain yourself
You don't need OpsNexus to check its own log. On the Docker Compose host, export the hashed fields as JSON lines straight from PostgreSQL:
docker compose exec -T db psql -U opsnexus -d opsnexus -At -c "
SELECT row_to_json(a) FROM (
SELECT organization_id, actor_id, action, resource_type, resource_id,
result, details, prev_hash, hash
FROM audit_logs ORDER BY created_at
) a" > audit.jsonlThen recompute every hash and check every link with this short script, which only uses the Python standard library:
import hashlib, json, sys
from collections import defaultdict
rows = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8") if line.strip()]
known = defaultdict(set) # hashes seen, per organization
starts = defaultdict(int) # entries with no previous hash, per organization
bad_hash = broken_link = 0
for r in rows:
payload = json.dumps(
[str(r["organization_id"]), str(r["actor_id"]), r["action"], r["resource_type"],
r["resource_id"], r["result"], r["details"], r["prev_hash"]],
sort_keys=True, default=str,
)
if hashlib.sha256(payload.encode()).hexdigest() != r["hash"]:
bad_hash += 1
print("hash mismatch:", r["hash"][:16], r["action"])
known[r["organization_id"]].add(r["hash"])
for r in rows:
org = r["organization_id"]
if r["prev_hash"] is None:
starts[org] += 1
elif r["prev_hash"] not in known[org]:
broken_link += 1
print("missing previous entry before:", r["hash"][:16], r["action"])
print(f"{len(rows)} entries, {bad_hash} bad hashes, {broken_link} broken links")
for org, n in starts.items():
if n > 1:
print(f"organization {org}: {n} chain starts (expected 1)")python3 verify_audit.py audit.jsonlA clean log prints zero bad hashes, zero broken links and no extra chain starts. A deleted entry shows up as a broken link. An edited entry shows up as a bad hash. A chain that was cut and restarted shows up as an extra start.
The str(...) calls matter. OpsNexus hashes the IDs as strings, so a missing organization or actor becomes the text None. The script does the same.
Why not something heavier?
We looked at signing entries, at external timestamping, and at shipping every entry to an append-only store. Each has a place, and the CSV export and the API make it easy to add them on top. For a self-hosted tool, though, the baseline has to work with nothing but PostgreSQL. It has to cost close to nothing on every write, and anyone should be able to check it with a 30-line script. A SHA-256 chain meets all three.
The Audit logs page in the docs covers filters, retention and export. The Security center page covers the rest of the security model.