OpsNexus
All posts
SecurityAuditEngineering

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.

ONOpsNexus team · 4 min read

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.create or secret.reveal;
  • a category (Sign-in, Access, Secrets, Deploy, Cloud…);
  • the resource type, ID and label;
  • the result: success, denied or failed;
  • a details object 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:

python
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 denied entry or edits details breaks 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.

bash
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).csv

The 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:

bash
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.jsonl

Then recompute every hash and check every link with this short script, which only uses the Python standard library:

python
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)")
bash
python3 verify_audit.py audit.jsonl

A 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.

Note

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.

Try it on your own servers

Free for up to 5 users. One Docker command to start.