Verification
Signed receipts, local consistency proofs, customer-held signing keys, RFC 6962 inclusion and consistency proofs, the offline CLI, and Rekor anchoring.
There are four independent ways to check an Audkit log, in increasing order of how little they trust Audkit:
- Ask the platform — a service-signed receipt that the tree rebuilds from row content.
- Check the platform's work — local hash arithmetic proving the served tree still contains a head you already saw.
- Sign events yourself — Ed25519 over the payload commitment, before the event leaves your infrastructure.
- Rebuild offline —
npx audkit verify fullagainst an export and a root you trust, with no network at all.
Server-side verification
verify() asks the platform to rebuild the stream's whole Merkle tree from row content —
decrypting each payload, re-deriving its nonce-blinded commitment, re-checking sequence
contiguity from 1 and every customer signature, recomputing every leaf — and to assert
the rebuilt (treeSize, rootHash) matches the live tree that proofs are served from.
const receipt = await audit.verify();type VerifyReceipt = {
receiptVersion: number;
stream: "events" | "project_audit";
projectId: string;
valid: boolean;
checkedCount: number; // leaves rebuilt
treeSize: number; // size of the tree they reproduce
rootHash: string; // the root they reproduce
firstBreak: ChainBreak | null;
verifiedAt: string;
servicePublicKey: string; // hex Ed25519 public key
signature: string; // Ed25519 over the receipt
};The receipt is signed, not HMAC'd — anyone holding servicePublicKey can check it,
including a third party you hand it to.
When something is wrong, firstBreak names the exact record and why:
type ChainBreak = { sequence: number; reason: ChainBreakReason; detail: string };reason | Meaning |
|---|---|
sequence_gap | Sequence numbers are not contiguous from 1 — a row was deleted. |
content_tampered | The stored payload no longer re-derives its recorded commitment. |
leaf_mismatch | The recomputed RFC 6962 leaf hash differs from the stored one. |
invalid_client_signature | A customer Ed25519 signature no longer verifies. |
root_mismatch | The rebuild is internally consistent but does not reproduce the live tree. |
The control plane has its own tree — project creation, key creation and revocation, member changes, retention changes, legal holds. Verify it separately:
const controlPlane = await audit.verify({ stream: "project-audit" });Available from the dashboard too, at Settings → Verification.
Tamper-evidence is out-of-band by construction
There is no edit or delete endpoint. Nothing the API exposes can rewrite history — so
what verify() detects is a forgery made around the API, against the database
directly. A superuser can still bypass triggers; what makes that detectable is the
signed, externally anchored roots below.
Local consistency checking
A platform grading its own homework proves little. The client also checks the platform's work with arithmetic it does itself.
It pins the last signed tree head (STH) it verified. On checkConsistency() it fetches
the current head, fetches an RFC 6962 consistency proof between the two, and verifies that
proof locally — a forged proof cannot reproduce a root the client already pinned.
await audit.checkConsistency(); // stream "events" by default
await audit.checkConsistency("project-audit");This also runs opportunistically after log() and query(): throttled, off the hot path,
never blocking the call. A network failure is not tampering, so transport errors are
swallowed and retried on a later interaction.
AudkitTamperError
Thrown when the arithmetic proves the served log is not an append-only extension of
history this client already saw. It carries stream, pinnedSth, currentSth, and:
reason | Meaning |
|---|---|
inconsistent_tree | The consistency proof failed — history was truncated, edited, or reordered. |
tree_shrunk | The served tree is smaller than the pinned one. |
bad_sth_signature | The served head does not verify under the expected service key. |
service_key_changed | The service public key changed mid-history — indistinguishable from a malicious fork. |
The error is deliberately loud: once raised, the client is poisoned and every subsequent call throws it again. There is no safe way to continue against a log that just failed a tamper check.
const audit = new Audkit({
apiKey: process.env.AUDKIT_API_KEY!,
onTamper: (error) => pageSecurity(error), // in addition to the throw
});Making the pin survive
The default sthStore is in-memory, which protects a single process lifetime. In a
serverless runtime the pin dies with the instance — exactly when a rewrite would go
unnoticed. Give the client a durable store to carry tamper-evidence across restarts:
import { Audkit, type SthStore, type StoredSth } from "audkit";
const store: SthStore = {
load: (key) => redis.get<StoredSth>(key),
save: (key, value) => redis.set(key, value),
};
const audit = new Audkit({ apiKey, sthStore: store });Related options: autoVerify (default true), autoVerifyIntervalMs (default 60_000),
and servicePublicKey — pin the platform's key explicitly instead of trusting it on first
use.
Bring your own signing key
With a customer-held key the history becomes unforgeable even by the platform.
Generate the keypair locally — the private key never touches Audkit:
npx audkit keygenimport { generateSigningKeyPair } from "audkit/protocol";
const { privateKey, publicKey } = generateSigningKeyPair();Register the public key in the dashboard under Project → Settings, then configure the client:
const audit = new Audkit({
apiKey: process.env.AUDKIT_API_KEY!,
signingKeyId: process.env.AUDKIT_SIGNING_KEY_ID!,
signingPrivateKey: process.env.AUDKIT_SIGNING_PRIVATE_KEY!,
});The Next.js and AI wrappers pick up AUDKIT_SIGNING_KEY_ID and
AUDKIT_SIGNING_PRIVATE_KEY from the environment automatically. Both must be provided
together — the constructor throws if only one is set.
What happens on each log():
- The SDK mints the event
idand a 32-byte blindingnonce. - It computes the payload commitment
sha256(nonce || JCS(payload))over the RFC 8785 canonicalized event. - It Ed25519-signs that commitment and sends
id,nonce,signingKeyId, andsignaturealongside the event. - Ingest re-derives the commitment from exactly the bytes it will persist and rejects the event unless the signature verifies against your registered public key.
Once a signing key is registered for a project, ingest rejects unsigned events rather
than silently accepting a downgrade. Signed events keep the client-chosen id and are not
enriched with server-derived IP or user-agent — that would inject bytes the signature
never covered.
Signatures outlive the payload
Retention crypto-shreds expired payloads, but the signature covers the commitment,
which the leaf retains. verify() keeps re-checking every customer signature — and
inclusion proofs keep verifying — long after the content itself is gone. Because the
commitment is nonce-blinded and the nonce lived inside the destroyed blob, a shredded
commitment cannot be dictionary-attacked to confirm what it once said.
Proofs
| Call | Returns |
|---|---|
audit.getRoot(stream?) | { sth, servicePublicKey } — the current signed tree head |
audit.getInclusionProof({ eventId | leafIndex, treeSize?, stream? }) | { stream, leafIndex, treeSize, leafHash, proof } |
audit.getConsistencyProof({ oldSize, newSize?, stream? }) | { stream, oldSize, newSize, proof } |
All three return hex-encoded audit paths meant to be checked locally. The same
primitives the platform's verifier uses ship in audkit/protocol, byte for byte:
import {
verifyInclusionProof,
verifyConsistencyProof,
verifyTreeHead,
} from "audkit/protocol";
const { sth, servicePublicKey } = await audit.getRoot();
verifyTreeHead(servicePublicKey, sth); // boolean
const { leafIndex, treeSize, leafHash, proof } =
await audit.getInclusionProof({ eventId });
verifyInclusionProof({
leafHash: hexToBytes(leafHash),
leafIndex,
treeSize,
proof: proof.map(hexToBytes),
rootHash: hexToBytes(sth.rootHash),
});An inclusion proof settles "is this exact event in the tree?" in O(log n). A consistency proof settles "is today's tree an append-only extension of the one I saw last month?".
Store receipts and heads over time and pin the (treeSize, rootHash) pairs. A later tree
that is not consistent with an earlier one proves history was truncated or rewritten —
even if that later tree self-verifies perfectly.
Offline verification
The CLI is pure local hash arithmetic over exported data. No network, no trust in the Audkit API — if verification required calling the API, it would prove nothing.
npx audkit verify full --export events.ndjson --root a8d772…Rebuild every leaf from the export, re-derive each unshredded event's payload commitment from its content plus nonce, check sequence contiguity, and compare the rebuilt root against the trusted one:
project: prj_…
events rebuilt: 18442
commitments re-derived: 18442
rebuilt tree size: 18442
rebuilt root: a8d772…
OK: full local rebuild reproduces the trusted root| Command | Flags |
|---|---|
audkit keygen | — |
audkit verify full | --export <file.ndjson> [--root <hex>] [--size <n>] [--signing-key <hex>] |
audkit verify inclusion | --leaf-hash <hex> --index <n> --size <n> --root <hex> --proof <hex,…> |
audkit verify consistency | --old-size <n> --old-root <hex> --new-size <n> --new-root <hex> [--proof <hex,…>] |
audkit verify sth | --sth <file.json> --service-key <hex> |
Exit codes are scriptable:
| Code | Meaning |
|---|---|
0 | Verified. |
1 | Tamper detected — the message says where. |
2 | Usage or input error. |
Pass --signing-key <hex> to verify full and every customer signature in the export is
re-checked too. Feed it the NDJSON export (format=json), not CSV, and export the whole
stream — a partial range trips the sequence check, because a truncated export and a
deleted event look identical from the outside.
External anchoring
A signed root only helps if the signer cannot quietly re-sign a different one later. So
every hour Audkit signs each stream's grown tree head into its append-only tree_heads
log and publishes it to Sigstore Rekor, a
public transparency log Audkit cannot edit.
Once a root is anchored, re-signing a different tree at the same size is cryptographic proof of misbehaviour — checkable by anyone, against a log Audkit does not control.
Pull the attested heads and their anchor receipts with a log:verify key:
GET /api/v1/anchors?stream=events&limit=20Each entry carries treeSize, rootHash, timestamp, signature, plus anchorType,
anchorRef, anchorProof, and anchoredAt once published. Resolve anchorRef at the
transparency log yourself — https://search.sigstore.dev/?uuid=<anchorRef> or
rekor-cli get --uuid <anchorRef> — then compare the anchored root against a local
rebuild or a consistency proof.
Email digests are a second, human-held anchor: each one carries both streams' signed tree heads, the latest externally anchored root, and any active legal-hold roots, sitting in inboxes Audkit also cannot reach into.
Related
- SDK reference — full signatures for every call above.
- REST API — the endpoints behind them, and the scopes they need.
- Quickstart — from zero to a first receipt.