> ## Documentation Index
> Fetch the complete documentation index at: https://docs.theauth.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Tamper-evident audit trail

> Chain audit rows with SHA-256 or HMAC hashes, verify the chain, replay what an agent did, and export signed evidence.

## Overview

The audit log records what agents do. By default nothing stops someone with database write access from editing or deleting a row afterwards. Turn on the hash chain and every new row stores the hash of the row before it, so an edit, a deletion or a reorder shows up the next time you verify.

It is off by default and additive. Rows written before you enable it are left alone and are reported as unchained.

```ts theme={"dark"}
import { createTheAuth } from "@glinr/theauth";

const theauth = await createTheAuth({
  database: { provider: "postgres", url: process.env.DATABASE_URL },
  agents: { enabled: true },
  audit: {
    tamperEvident: true,
    // Without a key anyone who can write to the database can recompute the chain.
    hmacKey: process.env.AUDIT_HMAC_KEY,
  },
});
```

## What gets recorded

With `agents.auditAll` on (the default), every decision about a known agent writes one row, allowed or denied. That includes a request from an agent that is revoked or expired. Those rows have `result: "denied"` and a stable `reason` code, so you can filter for them:

| `reason` | Written when |
| - | - |
| `agent_revoked` | `authorize(agentId)` or `authorizeByToken(token)` is called for a revoked agent |
| `agent_expired` | The same calls for an agent whose status is `expired`, or whose token has passed its expiry |

The `reason` string on the returned result is unchanged (for example `Agent "deploy-bot" is revoked`). The row stores the short code. The row never stores the token. It stores the action, resource, `arguments`, IP and user agent, like any other row, and it goes through the same writer, so with `tamperEvident` on it joins the agent's chain like any other row.

Some denials write no row:

* **Unknown agent id** (`authorize("no-such-agent")`). There is no agent to attach a row or a chain to, and the audit table requires a real agent. Writing rows keyed by arbitrary ids would let a caller fill the table with junk.
* **Unknown token** (`authorizeByToken` with a token that matches no agent). Same reason. Nothing identifies an agent.
* **Blocked by a `beforeAuthorize` hook.** The hook runs before any database work by design. Use `onViolation` to log these.
* **`agents.auditAll: false`.** No rows are written for any decision, including these.

When no row is written, `auditId` on the result is an empty string.

A failed audit write on one of these denials does not change the outcome. The call still returns `allowed: false` and does not throw, and `auditId` is empty. This differs from an allow or a permission denial, where a failed audit insert propagates as an error.

A caller holding a revoked token can produce one row per attempt. Use `audit.cleanup({ retentionDays })` and your gateway's rate limiting if that matters for you.

## How the chain works

There is one chain per agent. Each row gets `chain_seq` (1, 2, 3...), `prev_hash` and `hash`. The hash is SHA-256 over the canonical JSON (sorted keys) of the row fields, the sequence number and `prev_hash`. With `hmacKey` it is HMAC-SHA256 instead.

Why per agent and not per tenant or global: writers for different agents never contend, a replay is exactly one chain, and a single unique index on `(agent_id, chain_seq)` is enough to stop a fork.

Concurrent writers do not need a transaction or a lock. A writer reads the chain head, computes the next hash and inserts. If another writer took that sequence number first, the unique index rejects the insert and the writer re-reads the head and retries. This behaves the same on SQLite, Postgres, MySQL and D1. Writers inside one process also queue per agent, so retries only happen across processes.

Timestamps are hashed at one-second precision, because that is what the SQLite column stores.

### Migration

`createTables` adds the three nullable columns and the unique index to an existing `theauth_audit_logs` table. The statements are idempotent (Postgres uses `ADD COLUMN IF NOT EXISTS`, SQLite and MySQL tolerate "already exists"). If you run with `skipMigrations: true`, apply this yourself:

```sql theme={"dark"}
ALTER TABLE theauth_audit_logs ADD COLUMN chain_seq INTEGER;
ALTER TABLE theauth_audit_logs ADD COLUMN prev_hash TEXT;
ALTER TABLE theauth_audit_logs ADD COLUMN hash TEXT;
CREATE UNIQUE INDEX theauth_audit_logs_chain ON theauth_audit_logs (agent_id, chain_seq);
-- MySQL: use (agent_id(191), chain_seq)
```

Without the unique index, concurrent writers can fork a chain.

## Verify

```ts theme={"dark"}
const result = await theauth.audit.verifyAuditChain({
  from: new Date("2026-10-01"),
  to: new Date("2026-10-31"),
});

if (result.success && !result.data.ok) {
  const b = result.data.firstBreak!;
  console.error(`${b.reason} for agent ${b.agentId} at seq ${b.seq}`, b.rowIds);
}
```

Break reasons: `hash_mismatch` (a row was edited), `seq_gap` (a row was deleted), `prev_hash_mismatch` (rows were reordered or relinked), `missing_hash`, and `truncated`.

Deleting the newest rows of a chain leaves a shorter chain that still verifies. To catch that, keep a chain head somewhere the database owner cannot edit (an export manifest works) and pass it back:

```ts theme={"dark"}
await theauth.audit.verifyAuditChain({ expectedHeads: manifest.heads });
```

## Replay an agent

```ts theme={"dark"}
const replay = await theauth.audit.replayAgent("agent_123", {
  since: new Date("2026-10-01"),
  until: new Date("2026-10-02"),
  userId: "user_456", // optional: only what it did for this user
});

if (replay.success) {
  for (const e of replay.data.events) console.log(e.at.toISOString(), e.kind, e.summary);
  console.log(replay.data.verification.status); // "verified" | "broken" | "unchained"
}
```

The timeline merges audit rows (`action` when allowed, `decision` when denied or rate limited), delegations the agent gave or received, approval requests and answers, and token cost events, ordered by time. Only audit rows are covered by the chain, and each event says whether it is `chained`. The `userId` filter applies to audit rows and approvals.

## Export for compliance

```ts theme={"dark"}
const exported = await theauth.audit.exportAudit({
  agentId: "agent_123",
  since: new Date("2026-10-01"),
  until: new Date("2026-10-31"),
});

if (exported.success) {
  await writeFile("audit.jsonl", exported.data.jsonl);
  await writeFile("audit.manifest.json", JSON.stringify(exported.data.manifest, null, 2));
}
```

The manifest holds the SHA-256 of the file, the row count, the range, the chain head per agent, whether the chain verified, and an HMAC signature over all of that. It is signed with `audit.hmacKey` (or `signingKey` if you pass one); export fails with `NO_SIGNING_KEY` otherwise. Check an export later without a database:

```ts theme={"dark"}
import { verifyAuditExport } from "@glinr/theauth";

const check = await verifyAuditExport({ jsonl, manifest }, process.env.AUDIT_HMAC_KEY!);
console.log(check.ok, check.problems);
```

Exports are capped at 100,000 rows; narrow the range for more.

## CLI

```bash theme={"dark"}
export DATABASE_URL=postgres://...
export THEAUTH_AUDIT_HMAC_KEY=...   # same value as audit.hmacKey, if you set one

theauth audit verify --from 2026-10-01 --json
theauth audit replay --agent agent_123 --since 2026-10-01 --json
```

Both exit 1 when the chain is broken. `--db <url>` overrides `DATABASE_URL`. The commands never write.

## Limits

* Tail truncation is only detectable against a saved head (see above).
* Anyone holding `hmacKey` and database write access can forge a chain. Keep the key outside the database host.
* Retention cleanup and GDPR erasure remove or rewrite rows. Removing the oldest rows is not flagged; rewriting a row in the middle is.
* Audit rows written by the policy engine are not chained yet. They show up as unchained in verify output.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.