Audkit
Guides

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:

  1. Ask the platform — a service-signed receipt that the tree rebuilds from row content.
  2. Check the platform's work — local hash arithmetic proving the served tree still contains a head you already saw.
  3. Sign events yourself — Ed25519 over the payload commitment, before the event leaves your infrastructure.
  4. Rebuild offlinenpx audkit verify full against 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 };
reasonMeaning
sequence_gapSequence numbers are not contiguous from 1 — a row was deleted.
content_tamperedThe stored payload no longer re-derives its recorded commitment.
leaf_mismatchThe recomputed RFC 6962 leaf hash differs from the stored one.
invalid_client_signatureA customer Ed25519 signature no longer verifies.
root_mismatchThe 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:

reasonMeaning
inconsistent_treeThe consistency proof failed — history was truncated, edited, or reordered.
tree_shrunkThe served tree is smaller than the pinned one.
bad_sth_signatureThe served head does not verify under the expected service key.
service_key_changedThe 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 keygen
import { 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():

  1. The SDK mints the event id and a 32-byte blinding nonce.
  2. It computes the payload commitment sha256(nonce || JCS(payload)) over the RFC 8785 canonicalized event.
  3. It Ed25519-signs that commitment and sends id, nonce, signingKeyId, and signature alongside the event.
  4. 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

CallReturns
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
CommandFlags
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:

CodeMeaning
0Verified.
1Tamper detected — the message says where.
2Usage 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=20

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

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

On this page