Skip to main content

Verifying the audit chain

What a hash-chained audit trail is in plain terms, how to verify one, what linkage and content mean, and the difference between the two chains in the platform.

Written for an audit response10 min readUpdated

An audit log is only worth what its integrity is worth. A log that records everything and can be quietly edited afterwards is not evidence — it is a document, and a document proves whatever the last person to touch it wanted it to prove. This article explains the mechanism that makes the platform's records evidence rather than documents, how to check it yourself, and where the guarantee is weaker than you might assume.

What a hash-chained audit trail is

A hash is a short fingerprint computed from a piece of data. Change one character and the fingerprint changes completely, and you cannot work backwards from the fingerprint to the data. That is the only cryptography this article needs.

Chaining means that when a row is written, the fingerprint of the previous row is folded into the calculation of the new row's fingerprint. Each row therefore commits to the one before it, and by transitivity to every row before that. Two kinds of tampering become detectable, and they are worth naming separately because they implicate different people:

Broken linkage
A row's stored reference to its predecessor no longer matches the predecessor's actual fingerprint. This is what an inserted or deleted row looks like, or a chain re-linked to hide a gap. It implicates whoever can insert or delete rows.
Broken content
The fingerprint recomputed from a row's own current contents does not match the fingerprint stored on that row. This is what an edited row looks like — the row is in the right place in the sequence, but what it says has changed. It implicates whoever can update rows.

Detecting the second requires a specific discipline in the verifier: it must recompute the fingerprint from the row in front of it rather than trusting the fingerprint the row carries. That sounds obvious. It is the thing that was wrong here, on a live database, and the section on that is further down.

The two chains a compliance conversation is about

The platform runs six independent hash chains: your compliance record, the Qorionix operator ledger, privileged access actions, Sentinel-Q decisions, posture change proposals and threat-hunt runs. Each covers a different kind of privileged action and each is chained separately, so a problem in one does not cast doubt on the others. Only two of the six matter for the conversation you are having with an auditor, and conflating those two is the most common error in writing about this product — including in our own earlier documentation.

The two hash chains in the platform, what each records, and exactly what its verification returns
ChainWhat it recordsDigestWhat verification returns
The tenant compliance chain — compliance_audit_chainCompliance-relevant events for your organisation, as one independent chain per framework. Evidence-bundle stamps are written here under the framework evidence_bundle.Plain SHA-256, unkeyed, over the action, the actor, the timestamp and the payload, folded together with the previous row's hash.A single boolean. Both linkage and recomputation are checked, and if either fails the answer is false. It does not tell you which row broke or why.
The control-plane operator chain — controlplane_auditEvery mutating action taken by a Qorionix operator, including support-access grants and reads against your tenant. Read-only requests are not recorded.HMAC-SHA256 under a key held in a managed key service and readable only by the control-plane process. Nine columns are covered, with a length-prefixed encoding so a value containing a separator cannot be rearranged into a different but equal-hashing row.A structured result: rows checked, the id of the first failing row, a reason of linkage or content, and the first and last ids in the range examined.

How to verify your own chain

Verification is a read, it is cheap, and there is no reason not to run it before you send anything to anybody.

Verifying a tenant compliance chain
GET /api/v1/organizations/{orgID}/compliance/audit/verify?framework=nis2

permission: compliance:frameworks (read)
framework defaults to nis2 when the parameter is omitted

response: {"organization_id": "...", "framework": "nis2", "valid": true}
error:    {"error":"verify failed"} with HTTP 500

There is one chain per framework, so verify each framework you intend to rely on: nis2, gdpr, ai_act and dora, plus evidence_bundle for the stamps. A failure is not reported across the others — a broken GDPR chain does not make the NIS2 answer false. That narrows the problem, but it also means a single call is not a statement about the whole record.

  1. Verify before you generate, not after you have sent

    The audit-chain section of an evidence bundle reports per-framework verification as a boolean, and the bundle's front page summarises it as chains verified n/m. Reading that after the bundle has left your organisation is too late to be useful.

  2. Read a `false` as a question, not a verdict

    It means the replay did not reconcile. It does not by itself mean somebody tampered with your data — a botched restore, a partial migration or a manual database fix produces the same answer. What it does mean is that you cannot present that chain as intact, and that is worth knowing before an auditor asks.

  3. Raise an unexplained `false` as a security matter

    Treat it as a potential integrity incident in your own environment. Use the [SECURITY] route in How to get help rather than a normal ticket, and say which framework and which organisation.

What the operator chain reports, and why the detail matters

The control-plane chain covers actions taken by Qorionix staff rather than by your users. You do not call its endpoint, but its result carries five things that a support conversation about an integrity question will actually be about.

  • A verdict — intact when the first failing row id is zero.
  • The coverage of the check — rows examined, and the first and last ids in the range. This matters more than it looks: the walk is limited, defaulting to a thousand rows and clamped at a hundred thousand, so a verdict quoted without its range claims more than was verified.
  • The failing row id — so a dispute about tampering has one specific row to argue about rather than a general suspicion.
  • A reason — linkage or content, with the meanings given at the top of this article.
  • That the digest is keyed — re-chaining requires a secret the database host does not hold, so a party with full database access cannot silently rebuild a consistent-looking chain.

Operator actions against your tenant are also disclosed into your own audit log, so you do not depend on us verifying our own ledger in order to see them. That disclosure is the customer-facing half of the design and it is not optional.

Why the verification result is worth trusting now

Because it was not, and the way that was discovered is the reason we are comfortable telling you to rely on it. Every hash chain in this codebase was found broken at least once, and not one of the defects was found by reading the code. Code review had looked at them and pronounced them fine. They were found by tampering with a live database, and by watching intact chains report themselves broken.

The most serious was on the operator chain. Its first implementation chained with a plain SHA-256, and verification compared only each row's stored reference to the preceding row's hash. It never recomputed anything, which means it detected an insertion or a deletion and nothing else:

It was demonstrated against a live database: rewriting a single row's actor column — the field that attributes a privileged cross-tenant action to a named human — left Verify reporting 'intact'. An audit ledger whose attribution can be edited without detection is worse than no ledger, because it is trusted.
internal/controlplane/middleware/audit.go, on why the digest is keyed

Two changes followed. Verification now recomputes each row's digest from the row's own contents rather than trusting the stored value, which is what makes content a reportable reason at all. And the digest became an HMAC under a key the database host does not have.

The other defects were one mistake wearing three costumes: hashing a value that differs from the value the database actually stores and returns. They failed in the opposite direction — chains nobody had touched reporting themselves broken — and that direction is just as corrosive, since a verifier that always says broken is a verifier nobody consults.

  • Fixed-width text columns silently pad. Two chains declared their hash columns as a fixed 64 characters. The first row of every chain has an empty predecessor hash by definition, and the database returned that as 64 spaces — so verification compared 64 spaces against nothing and reported row one as broken, for every chain that had ever existed. The hashes were computed and stored correctly; only the read-back was padded.
  • Timestamps have different precision in different places. The database stores microseconds; the language's clock carries nanoseconds. Hashing before the write and recomputing after it produced two different answers, every time.
  • A JSON column is not a byte store. The database parses it, reorders object keys, drops duplicates and normalises numbers, so what comes back out is not what went in.
Every hash-chained log in this codebase was found broken by the same mistake: computing a hash over a value that differs from the one Postgres stores and returns. […] Each produced a chain that either failed to verify when intact or, worse, would have verified when tampered with.
internal/pam/auditchain.go, the retrospective that shaped the newest chain

What the chain does not give you

Four limits, all of them things a competent reviewer will eventually ask about. Volunteering them costs a sentence each.

  • The tenant compliance chain is unkeyed. Its digest is a plain SHA-256, so verification detects an edit made by someone who did not also rebuild the chain — accidental corruption and casual tampering — but a party with full write access to your compliance tables could in principle re-chain it consistently. The operator chain is keyed precisely because it attributes actions to Qorionix staff. If a reviewer asks whether your compliance chain is protected against a database administrator, the answer is that it is tamper-evident, not tamper-proof.
  • It is not a write-once archive. Long-term immutable export to object storage is not implemented: the exporter is a stub and the hourly export routine has no caller. There is no second, independent copy of the chain outside the database. If your retention policy requires one, record it as a gap.
  • Only one of the six chains has a verification endpoint you can call. Your compliance chain does. If you need assurance about, say, the privileged-access chain over a specific window, that is a support request. Say "the compliance chain verifies" rather than "the audit trail verifies" if that is what you actually checked.
  • A stamp is a SHA-256, not a signature. Stamping an evidence bundle proves a bundle with that hash existed at that time and was recorded. It is not a digital signature verifiable by an outside party independently of us.

What to read next

  1. What an auditor will ask for — the ten requests, and which of them the chain answers.
  2. The evidence bundle: what an auditor gets — how stamping ties a bundle to the chain.
  3. NIS2: what your management is personally responsible for — why the one-month final report depends most on this record.

Was this article wrong?

If a procedure here does not match what you see, or a limit we described has changed, tell us and we will fix the page. Email us about this article, or see how to get help if you need an answer rather than a correction.

Everything in compliance evidence