Audit logs you can verify: how hash chains work
An audit log is only evidence if nobody can quietly edit it. How hash chains make tampering visible, what they miss, and how to check an export yourself.
Crunu teamPublished 7 min read
On this page
An audit log answers questions after the fact: who granted that access, when was this value changed, which machine fetched production last night. Those answers are only useful if the log itself can be trusted. In most systems, the log is a table in a database, and anyone who can write to the database can edit the past. An attacker who got in can remove the lines that show how. An administrator who made a mistake can make it disappear.
A hash chain does not stop anyone from writing to the database. It makes the edits visible. This post explains how that works, what it does not catch, and how to check a Crunu audit log export with a short script of your own.
What a hash chain is
A cryptographic hash function, such as SHA-256, turns any input into a short fixed-size value. Change one byte of the input and the output changes completely, and there is no practical way to construct a different input with the same output.
A hash chain uses that property to link records. Each new entry stores the hash of the entry before it, and then its own hash is computed over everything, including that link:
entry 1: { event…, prevHash: (start) } hash1 = SHA-256(entry 1)
entry 2: { event…, prevHash: hash1 } hash2 = SHA-256(entry 2)
entry 3: { event…, prevHash: hash2 } hash3 = SHA-256(entry 3)Now try to change entry 2. Its hash changes, so entry 3's prevHash no longer matches. To hide that, you would have to recompute entry 3, which changes its hash, which breaks entry 4, and so on to the end of the log. Deleting an entry or inserting one in the middle breaks the chain in the same way.
To check the log, you walk it from the start, recompute every hash and compare. The first mismatch shows where the history was changed.
What it catches and what it does not
A hash chain is tamper-evident, not tamper-proof. Being precise about the difference is what makes it useful.
It catches:
- an entry edited after it was written;
- an entry deleted from the middle;
- an entry inserted between two others.
On its own, it does not catch:
- A rewrite from some point onward. Someone with full control of the database can recompute every hash after the entry they changed. The chain is then internally consistent, just different.
- Removed entries at the end. If the most recent entries are deleted, the remaining chain is still valid. Nothing points forward to the missing ones.
- Events that were never logged. A chain protects what was written, not what should have been.
The answer to the first two is an anchor outside the system: a copy of the latest hash kept somewhere the log's operator cannot change. If a later check shows a lower last entry number than the one you saved, or a different hash for the same number, the log was altered. Certificate Transparency takes this idea further for TLS certificates, with public append-only logs built as Merkle trees and proofs that a newer log extends an older one. Most products, Crunu included, do not run a public transparency log, which makes your own external copies the anchor.
How Crunu's audit log works
Each Crunu organization has its own audit log, and its entries are linked in a hash chain. Every entry stores the hash of the previous one, and its own hash is the SHA-256 of the entry in canonical JSON form, encoded as base64url. The entry is written in the same database transaction as the action it records, so an action cannot happen without its entry.
The log records sign-ins and devices, invitations and confirmations, role and access changes, saved versions, key rotations, machines and their enrollment, service tokens, share links, denials and network rule changes. Values never appear in it, because the server never has them.
Three things are worth knowing:
- Verify integrity. Owners and admins can check the chain from the web app under Audit log with Verify integrity. The result shows how many entries were checked, from which entry, and whether the chain is intact, or the number of the first broken entry. The organization status page also shows the last result.
- Client-reported events. Showing, copying and exporting a value happen on your device, so the server only knows about them if the client tells it. Those entries are signed by the person's key and marked Client-reported. They are useful as a record of normal behavior, but a modified client could skip them.
- Retention with checkpoints. The server keeps the log for 365 days by default. An organization can extend that, up to 36,500 days or indefinitely, but cannot shorten it. When old entries are trimmed, the server keeps a checkpoint with the number and hash of the last removed entry and writes a trim event into the chain, so verification from the checkpoint onward stays complete.
Separately from the audit log, the server records which person or machine received which version of each environment, when, how many times and from which address. That record is what the exposure report is built from when someone leaves.
Anchoring the log yourself
Because a chain cannot prove that its last entries were not removed, Crunu's operations guidance suggests a simple routine: once a week, write down the number and hash of the latest entry somewhere outside the server. A ticket, a note, an email to yourself are all fine. The integrity check shows both values.
At the next check, compare. If the latest number is lower than what you saved, or the hash for that number differs, the log was changed. That is a reason to investigate whether the server was compromised or the database edited by hand.
If you need the log for longer than the retention period, for an audit or a regulation, export it before it is trimmed.
Checking an export yourself
Export JSONL in the audit log downloads one entry per line, in exactly the form its hash was computed from, plus the hash field. To check it, remove hash from each line, serialize the rest as canonical JSON, take SHA-256, encode it as base64url without padding, and compare with hash. Then check that each line's prevHash equals the previous line's hash.
Canonical JSON here means object keys sorted, no whitespace, and strings encoded as in standard JSON. This Node.js script does the whole check with no dependencies:
// verify-audit.mjs: node verify-audit.mjs audit.jsonl
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
// Canonical JSON: object keys sorted, no whitespace.
const canon = (v) =>
Array.isArray(v) ? `[${v.map(canon).join(",")}]`
: v !== null && typeof v === "object"
? `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(",")}}`
: JSON.stringify(v);
const lines = readFileSync(process.argv[2], "utf8").split("\n").filter(Boolean);
let prev = null;
for (const line of lines) {
const { hash, ...record } = JSON.parse(line);
const actual = createHash("sha256").update(canon(record)).digest("base64url");
if (actual !== hash) throw new Error(`seq ${record.seq}: hash mismatch`);
if (prev && record.prevHash !== prev) throw new Error(`seq ${record.seq}: chain broken`);
prev = hash;
}
console.log(`${lines.length} records OK, last hash ${prev}`);It prints the number of entries and the last hash, which is the value to save outside the server. If the export does not start at the first entry, because older entries were trimmed, the first line's prevHash equals the hash of the checkpoint, which the integrity check reports along with its result. The script skips that first link and checks everything after it.
Running the same script against each weekly export, and keeping the printed last hash, gives you an anchor that does not depend on Crunu's server at all.
What to look for in the log
A verified log is only valuable if someone reads it. A few events are worth a regular look:
- Break-glass access opened by an owner to bypass network rules, and the reason given.
- Network rule relaxations, especially ones confirmed by a second admin rather than waiting out the 15-minute delay.
- New devices for owners and admins.
- Machines signing in from a new address, and any enrollment code presented twice.
- Values shown or exported, particularly from production, close to someone's departure.
- Access granted directly rather than through an access profile, which tends to be forgotten when roles change.
Many of these also raise alerts, which can go to a webhook or email. The log is where you go to see the full sequence around an alert.
Limits worth stating
- The chain proves the log was not altered after the fact. It does not prove that the server recorded everything; a compromised server could skip writing entries from that point on.
- Client-reported events rely on an honest client.
- Without your own saved hashes, removal of the newest entries is not detectable.
- The log contains metadata: names, email addresses, addresses. Treat exports as sensitive documents.
A hash chain is a modest mechanism. It does not make a log true. It makes a log that has been changed impossible to pass off as unchanged, which is most of what you need from evidence.
More in audit log, audit log hash chain and the audit log feature.
Sources
- RFC 6962: Certificate Transparency, checked October 27, 2026.