Skip to content
AuditKit

Security and threat model

An audit log is made immutable by making every change detectable by someone who does not trust the operator: hash-chain the events, sign the receipts, batch roots into a Merkle tree, and publish the roots somewhere the operator cannot edit. This page says exactly who can do what to your data in AuditKit.

Last updated 2026-10-05

Why this matters

Logs get edited by the people who run the systems. Ubiquiti's senior developer "altered the company's log retention histories and changed session file names to hide his activity and make it look like a coworker was sneaking around on the network" and was sentenced to six years (The Register, 2023). CISA's advisory on Volt Typhoon describes the actor "selectively clearing Windows Event Logs, system logs, and other technical artifacts" and recommends that organisations "store logs in a central system" (AA24-038A). WorkOS puts the engineering view plainly: "If an attacker with database access can quietly edit the evidence, the log is worse than useless — it's a false sense of security" (WorkOS blog). Practitioners on Hacker News say the same about local immutability: "If you want immutable logs, you log to an external log server. Anything else seems security theater to me."

So the question for any hosted log, including this one, is: what can an insider with database access do, and would you find out?

What a database administrator at AuditKit can do

  • Read every event and payload that has not been erased. Payloads are stored as JSON, not encrypted at rest by the application. Do not log secrets; log that they were used. [OWNER: state disk encryption on the VPS]
  • Delete rows, files or the whole database. This is detectable: a tenant's exported events no longer reach the roots in Rekor, and receipts you hold have positions that no longer exist.
  • Modify rows. Any change to a header field changes event_hash, which breaks prev_hash of the next event, the tenant root, the project root and the global root already in Rekor and OpenTimestamps. The verifier reports the first failing position.
  • Stop anchoring or stop the service. Visible on /anchors and in anchored_through on every verify. Receipts already issued still verify against the published key.

What a database administrator cannot do

  • Rewrite history that has been anchored, because the global root is in two public logs we do not operate and the verifier checks the rebuilt root against them.
  • Forge a receipt, because the Ed25519 private key is loaded from the environment of the server process and is not in any database; the public key is published at /.well-known/auditkit.json and you pass it to the verifier with --pin.
  • Forge your client signatures, if you sign events with a key we never see. Register the public key per tenant and the server verifies each signed event on ingest and, with the policy set, refuses unsigned ones; the signature travels in the export and the verifier checks it again with your public key, so you do not have to trust the server's check.
  • Recover an erased payload, because erasure deletes both the payload and its per-event salt; the remaining commitment is sha256(salt ∥ payload) with the salt gone.
  • Erase silently, because every erasure (and every retention shred) is appended to the tenant's chain as a payload.erased or payload.retention_shred event, signed and anchored like any other.
  • Silently drop or reorder an acknowledged event, because your receipt carries the position and hash the server signed; a later export that lacks it, or has something else at that position, fails verification.

The honest residual: between ingest and the next anchor tick (daily, 5 minutes or 1 minute by plan) an insider could delete a tenant's newest events and the public logs would not yet show them. Your receipts would. If that window matters, keep receipts, or sign client-side, or both.

Hashing and signing

  • Canonical JSON per RFC 8785 (JCS) at every level.
  • event_hash = sha256(JCS({id, project_id, tenant_id, position, occurred_at, actor, action, target, payload_commit, prev_hash})); prev_hash of position 0 is 64 zero hex digits.
  • payload_commit = sha256(salt ∥ JCS(payload)), 16-byte random salt per event.
  • Merkle leaves are sha256(0x00 ∥ event_hash), nodes sha256(0x01 ∥ left ∥ right); an odd node carries up unchanged (RFC 6962), never duplicated. Three levels: tenant root, project root, global root.
  • Server signatures: Ed25519 over the raw 32 bytes of event_hash. Key rotation: a new key is published under a new hostname entry in the verifier's pinned list; old entries are never edited. [OWNER: production key is not yet generated; PINNED_KEYS in packages/verify is empty until it is]

Anchoring policy

  • Every interval, for each tenant with new events: build the tenant root, then the project root, then one global root per tick. One Rekor entry per tick keeps public-log traffic polite.
  • Rekor: hashedrekord entry; we store the entry, inclusion proof and checkpoint. Final on write. Rekor does not give a trustworthy timestamp; we do not claim one.
  • OpenTimestamps: submitted to public calendars; the .ots proof is stored and upgraded when the Bitcoin attestation lands, within hours. Time comes from the block header.
  • Retries with backoff. Each receipt's status, pending | final, is visible per root on /anchors; a root with no receipt yet is still being anchored.

Keys, sessions, tenancy

  • API keys: random, shown once, stored as SHA-256 hash plus prefix; scopes read, write, erase, admin; revocation is immediate.
  • Dashboard sessions: passwordless magic link, single-use, 15-minute expiry, httpOnly cookie, SameSite=Lax, Secure in production. No passwords are stored.
  • Isolation: one SQLite file per project; tenants are rows within it, with separate chains and roots. Viewer tokens are read-only and scoped to one tenant.
  • MCP: same process and scopes as REST; Claude connects through OAuth 2.1 (PKCE, dynamic client registration, project-scoped tokens issued by this server) or an API key; no erase tool is exposed to agents.

We audit ourselves

AuditKit's own platform events go into a hash-chained, anchored system project that anyone can read, verify and export without an account: /audit.

Disclosure

Report vulnerabilities to security@auditkit.dev. We acknowledge within 3 business days and publish fixes with credit if you want it. [OWNER: confirm mailbox and response time]