Audit Log

Append-only record of org-scoped events. Powers the audit UI and supports compliance review.

Files

  • Schema: src/lib/db/schema/audit.tsaudit_logs table.
  • Helper: src/lib/api/audit.tslogAudit(args) fire-and-forget insert, plus signAuditRow / classifyAuditRow / verifyAuditRow / auditSigningEnabled / auditSigningCutover.
  • Tests: src/lib/api/audit.test.ts — golden digests over the canonical byte string, plus one case per verification bucket.
  • List endpoint: GET /api/internal/audit?limit=50 (developer+) — see Who can read it.
  • Verify endpoint: GET /api/internal/audit/verify (owner-only) — see Tamper evidence.

Who can read it

Reads are gated to developer or owner (requireDeveloperSession), and this is a deliberate policy call rather than an oversight: audit rows carry every colleague's IP address and user agent on each sign-in, invitee email addresses, and — with user.login / user.login_failed now emitted — a per-person record of when and from where each teammate signs in. That is surveillance-adjacent data with no read-only use case, so viewer and member do not get it. Writes are unaffected — every role's actions are still recorded.

Enforced in three layers, only the first of which is load-bearing:

  • GET /api/internal/audit answers 403 { error: "Developer or owner role required" } below developer. This is the actual gate.
  • /overview/settings/audit (page.tsx) checks requireDeveloperSession() server-side and bounces to the settings hub, so a typed-in URL never renders the page shell.
  • The sidebar entry and the settings-hub card are hidden below developer (useCanDevelop() client-side, ctx.role server-side) — cosmetic, so nobody is offered a link that 403s.

GET /api/internal/audit/verify stays owner-only, unchanged: a tampering report is an org-security answer.

Actions emitted

Defined in AuditAction (src/lib/api/audit.ts). Grouped by resource:

Users + sessions

Action Emitter
user.registered /api/auth/register
user.login NextAuth jwt callback (src/lib/auth/options.ts), in the fresh-login branch that mints the user_sessions row — the one choke point every door funnels through exactly once. metadata.method names the door: credentials (password + emailed code), google, github, or saml. Filed under the sign-in default org (oldest membership)
user.login_failed authorize() in src/lib/auth/options.ts (failed emailed-code gate, failed password compare, failed TOTP/backup code) and /api/auth/verify-login-code (failed standalone code check), via recordLoginFailure (src/lib/auth/login-failure-audit.ts). metadata carries the attempted email, ip and the stage that refused (code | password | 2fa); actor_user_id stays NULL — the actor proved nothing. Only for addresses with an existing account (org_id is a NOT NULL FK, and rows for arbitrary typed-in addresses would be spray-writable garbage), and the emission itself never changes the response shape or timing of the endpoints it instruments — the existence branch runs after the response flushes. Writes are rate-bounded at 10 per (email, ip) per hour so a password spray cannot flood audit_logs; attempts beyond the bound still fail exactly as before, they just stop adding rows
user.email_verified /api/auth/verify-email-code (signup code), /api/auth/verify-email (legacy link) — both pick the org with ORDER BY created_at, id, since an unordered limit(1) writes a personal account event into whichever org a multi-membership user's planner returns, i.e. another tenant's log
user.phone_verified /api/auth/verify-phone
user.password_reset /api/auth/reset-password (also bumps session_version)
user.sessions_revoked /api/internal/sessions DELETE
user.2fa_enabled /api/internal/two-factor/verify (the confirm step — setup alone stores nothing enabled)
user.2fa_disabled /api/internal/two-factor/disable
user.data_exported /api/internal/user/data-export
user.account_deleted /api/internal/user DELETE (also nulls actor_user_id on the user's rows — see Known benign invalids)

The four personal routes above (sessions, two-factor, data-export, and the user DELETE) run on identity alone, because they must work for a user who belongs to no org. logPersonalAudit() (src/lib/auth/personal-audit.ts) files their rows under the user's active org, resolved the way a dashboard request resolves it; for user.account_deleted that is resolved after any owned orgs have been deleted, so the row never lands in an org the erasure just cascaded away. A user with no org left has nowhere to file one (org_id is NOT NULL), so the same facts go to the operational log as audit.personal_no_org instead. The session and 2FA routes emit through schedulePersonalAudit(), which runs it in after() and logs a failure as audit.personal_failed: fired as a floating promise just before the response, the row could be dropped when the platform froze the instance.

API keys

Action Emitter
api_key.created /api/internal/api-keys POST
api_key.updated /api/internal/api-keys/[id] PATCH (rate limit, IP allow-list, etc.)
api_key.revoked /api/internal/api-keys DELETE

Domains

Action Emitter
domain.added /api/internal/domains POST
domain.removed /api/internal/domains/[id] DELETE
domain.verified /api/cron/domain-verify-poll (also v1 manual verify)
domain.unverified /api/cron/domain-verify-poll when DKIM revoked
domain.region_updated /api/internal/domains/[id]/region
domain.tenant_rebound /api/internal/domains/[id]/tenant
domain.warmup_started /api/internal/domains/[id]/warmup

Webhooks

Action Emitter
webhook.created /api/internal/webhooks POST
webhook.updated /api/internal/webhooks/[id] PATCH
webhook.removed /api/internal/webhooks/[id] DELETE
webhook.rotated /api/internal/webhooks/rotate
webhook.delivery_replayed /api/internal/webhook-deliveries/[id]/replay
webhook.test_fired /api/internal/webhooks/[id]/test

Templates + audiences + contacts

Action Emitter
template.created /api/internal/templates POST
template.updated /api/internal/templates/[id] PATCH
template.removed /api/internal/templates DELETE
audience.created /api/internal/audiences POST
audience.removed /api/internal/audiences DELETE
audience.csv_imported /api/internal/audiences/[id]/import-csv
audience.sent /api/internal/audiences/[id]/send
contact.created /api/internal/contacts POST
suppression.added /api/v1/suppressions POST + auto-suppression paths
suppression.removed /api/v1/suppressions DELETE + /api/internal/suppressions DELETE. Metadata carries channel, value and reason on both, and resourceId is the removed row's sup_ id, so the trail answers "who re-enabled this address, and what had been blocking it". Written only for a row that was actually removed — a delete of an unlisted recipient is a 200 with no entry, and a row refused with SUPPRESSION_PROTECTED writes none either (it logs suppression.remove_refused instead)

Tenants (platform mode)

Action Emitter
tenant.created /api/v1/tenants POST + /api/internal/tenants POST
tenant.suspended /api/v1/tenants/[id]/suspend POST
tenant.unsuspended /api/v1/tenants/[id]/unsuspend POST + /api/internal/tenants PATCH (unsuspend)
tenant.archived /api/v1/tenants/[id] DELETE + /api/internal/tenants DELETE
tenant.quota_updated /api/v1/tenants/[id]/quota PATCH

Messaging pools

Action Emitter
pool.created /api/internal/messaging-pools POST
pool.updated /api/internal/messaging-pools/[id] PATCH
pool.removed /api/internal/messaging-pools/[id] DELETE

SMS registration (10DLC + toll-free)

Action Emitter
brand.created /api/v1/brands POST
brand.updated /api/v1/brands/[id] PATCH, /api/internal/brands/[id] PATCH
brand.removed /api/v1/brands/[id] DELETE, /api/internal/brands/[id] DELETE
brand.verified /api/cron/sms-verify-poll, /api/internal/brands/[id] PATCH (empty body)
brand.unverified /api/cron/sms-verify-poll, /api/internal/brands/[id] PATCH (empty body)
campaign.created /api/v1/campaigns POST
campaign.updated /api/internal/campaigns/[id] PATCH
campaign.removed /api/v1/campaigns/[id] DELETE
campaign.verified /api/cron/sms-verify-poll
campaign.unverified /api/cron/sms-verify-poll
phone_number.provisioned /api/v1/phone-numbers POST
phone_number.released /api/v1/phone-numbers/[id] DELETE
phone_number.verified /api/cron/sms-verify-poll
phone_number.unverified /api/cron/sms-verify-poll

Team

Action Emitter
member.invited /api/internal/team/invite
member.joined /api/internal/team/accept
member.removed /api/internal/team/members DELETE
member.role_changed /api/internal/team/members PATCH

Billing / org

Action Emitter
plan.upgraded /api/webhooks/stripe on checkout.session.completed / customer.subscription.updated → active
plan.downgraded /api/webhooks/stripe on subscription deletion or non-active status
org.exported /api/internal/org/export/async (completion)
org.spend_cap_updated /api/internal/billing PATCH (owner-only). metadata records the old and new cap, so a send later refused with SPEND_CAP_EXCEEDED can be traced to who set the ceiling
org.sms_geo_updated /api/internal/org/sms-geo PATCH (owner-only) — records the full stored destination allowlist, since the interesting question after a blocked send is what the list was at the time
org.suspended / org.unsuspended scripts/suspend-org.mjs, the operator kill switch — the one sanctioned direct-to-database writer, so these rows are written in raw SQL and carry no HMAC signature (they predate no cutover; they simply have no application request behind them). metadata.via fingerprints the operator invocation, and suspension metadata carries the reason. See _org-suspension.md
org.deleted DELETE /api/internal/org (via deleteOrgWithBilling). Written after the cascade, which would otherwise delete it along with the org, and therefore filed under the actor's longest-standing surviving org that they ownaudit_logs.org_id is a NOT NULL FK to a row that no longer exists. Owned rather than merely joined because the metadata carries the deleted org's Stripe customer id, canceled subscription ids, invoice ids and settled amount, which must not land in a bystander tenant's log or export. An actor who owns nothing else (or the GDPR-erasure path, whose memberships are already gone) gets no row at all — the org.deleted log.warn line, with the same fields, is then the record. See billing → deleting an organization
org.ownership_transferred /api/internal/user DELETE — owner_id re-pointed to an heir. metadata.promoted_to_owner marks the case where no owner existed and a member was promoted. See Permissions → Ownership succession
audit.verified /api/internal/audit/verify — the signature check audits itself

Invite tokens

member.invited rows carry the invited address in resource_id. They used to carry the raw invite token, which is bearer-equivalent — possessing it is enough to accept the invite as the person it was addressed to — and this endpoint was, at the time, readable by every member of the org, viewer included (now developer+).

Historical rows still hold the token in the database. They are deliberately not rewritten: resource_id is one of the fields the row signature covers, so an in-place edit would either invalidate every affected row or require re-signing it, and a lawful cleanup that re-signs is indistinguishable from tampering to whoever reads the next verify report. redactAuditInviteToken (src/lib/api/audit.ts) withholds the value on the way out instead — applied by GET /api/internal/audit, the sync org export and the export cron. Nothing is lost: metadata.email carries the address either way.

Tokens expire 7 days after issue, so rows older than that were inert regardless.

Shape

Each entry:

{
  id: "aud_...",
  orgId: "org_...",
  actorUserId: "usr_..." | null,
  action: "api_key.created",
  resourceType: "api_key" | null,
  resourceId: "key_..." | null,
  metadata: { ... } | null,
  ipAddress: "1.2.3.4" | null,       // from x-forwarded-for
  userAgent: "...",                  // request header
  signature: "<sha256 hex>" | null,  // null when signing is off — see below
  createdAt: Date,
}

GET /api/internal/audit projects these columns explicitly rather than select()-ing the table, so a column added later is not served to readers by default. Two differences from the stored row: signature is omitted (the digest is what /api/internal/audit/verify reports on), and resource_id is withheld on invite rows — see above.

Usage

import { logAudit } from "@/lib/api/audit";

logAudit({
  orgId,
  actorUserId,
  action: "api_key.revoked",
  resourceType: "api_key",
  resourceId: keyId,
  req,
}).catch(() => {});

Always .catch(() => {}) — audit logging should never block the primary action.

Tamper evidence (row signatures)

Application code only ever appends here (the retention cron deletes aged rows; the only rewrite is GDPR erasure, which re-signs what it anonymizes). That says nothing about someone holding a database connection, so each row carries an HMAC and an after-the-fact edit can be detected without trusting the database.

Signing

logAudit computes HMAC-SHA256 over the row's immutable fields and stores the hex digest in signature. The key is AUDIT_SIGNING_SECRET, which lives only in the app env — an attacker with write access to audit_logs can change a row but cannot produce a matching signature for it.

Signed payload, in order: id, org_id, action, actor_user_id, resource_type, resource_id, metadata, created_at (ISO 8601). metadata is canonicalized first — JSON round-trip plus a deep key sort — because jsonb does not preserve key order, so signing the in-memory order would make every multi-key row unverifiable by construction.

The exact signed byte string is pinned by golden digests in src/lib/api/audit.test.ts. Changing the canonicalization re-derives every existing row to a different HMAC, so a change there is a schema-style migration: bump the cutover below in the same commit, and expect the goldens to fail — that failure is the point, not something to re-baseline.

ip_address and user_agent are not signed. They're request-derived rather than part of the recorded action; edits to those two are not detected.

Signing is opt-in and has no fallback secret. With AUDIT_SIGNING_SECRET unset, signAuditRow returns null and rows are written with signature = NULL — audit writes themselves are unaffected. See env.example.

The cutover

AUDIT_SIGNING_CUTOVER (ISO 8601) is the instant from which a row is required to carry a signature that re-derives under the current scheme. It is the boundary between "unverifiable" and "tampered", and the verifier will not call anything older than it a failure:

  • rows written before signing landed (or while AUDIT_SIGNING_SECRET was unset) carry no signature at all → unsigned;
  • rows signed before the deep key sort hashed metadata in its in-memory key order, which jsonb did not keep. For metadata with two or more keys at any depth that HMAC cannot be reproduced by anyone → legacy.

Unset, it defaults to 2026-08-10T00:00:00Z, the day after the key sort shipped. Set it explicitly when this deployment picked that change up later, or set AUDIT_SIGNING_SECRET later — the correct value is the later of those two instants. An unparseable value logs audit.cutover_unparseable and falls back to the default.

Erring late costs detection (rows land in legacy, which reads as "cannot tell"); erring early manufactures tamper alerts for rows nothing touched. The default rounds up a day for that reason.

The boundary is a timestamp rather than a version marker inside signature because it is a fact about the deployment, not about the row — and the stripped-signature case (signature IS NULL) has no marker to carry. No schema change, no format migration.

Verifying

GET /api/internal/audit/verify

Owner-only (requireOwnerSession) — a tampering report is an org-security answer, and the route reads back every signed field of every row it checks. Scoped to the caller's active org. Without this reader the signature column is write-only.

Param Default Notes
limit 100 max 500 — bigger page than the list endpoint; the response carries only failures + tallies
cursor compound cursor from a previous next_cursor
created_after ISO 8601 timestamp
created_before ISO 8601 timestamp

Newest-first, same (created_at, id) keyset as the list endpoint. A full sweep means walking pages; there is no full-table-scan-in-one-request mode.

{
  "checked": 100,
  "ok": 92,
  "unsigned": 5,
  "legacy": 2,
  "invalid": [
    {
      "id": "aud_...",
      "action": "api_key.revoked",
      "actorUserId": "usr_...",
      "resourceType": "api_key",
      "resourceId": "key_...",
      "createdAt": "2026-08-09T11:04:22.310Z"
    }
  ],
  "stripped": [],
  "cutover": "2026-08-10T00:00:00.000Z",
  "has_more": true,
  "next_cursor": "MjAyNi0wOC0wOVQ..."
}

checked = ok + unsigned + legacy + invalid.length + stripped.length. cutover is echoed because unsigned and legacy only mean "unverifiable" relative to that instant.

Bucket Meaning
ok Signature re-derived and matched. The signed fields are exactly what was written.
unsigned signature IS NULL on a row written before the cutover. Not tampering — written before signing landed, or while AUDIT_SIGNING_SECRET was unset. Nothing can prove or disprove these rows: read them as out of scope, not as clean.
legacy Signature present on a pre-cutover row that cannot be re-derived, and whose metadata carries two or more keys at some depth — so the old in-memory key order it was signed with is gone. Unverifiable by construction, not a finding. Pre-cutover rows whose metadata has at most one key per object are still checked exactly: legacy is not a blanket skip of everything old.
invalid Stored signature ≠ re-derived signature, and canonicalization does not explain it. The row changed after it was written — or one of the known benign causes applies. Investigate. Listed, not just counted.
stripped No signature (or an empty string) on a row written after the cutover. logAudit writes NULL or 64 hex chars and never "", so this is a signature that was cleared in place — the cheapest way for someone with database write access to launder an edit, since a blank column used to be filed under the benign unsigned tally. Treat exactly like invalid.

Errors:

Status Body When
403 { error: "Owner role required" } caller isn't an owner of the active org
503 { error: "...", code: "AUDIT_SIGNING_DISABLED" } AUDIT_SIGNING_SECRET unset — nothing can be re-derived, so every signed row would come back invalid; the route reports config state rather than a page of false alarms
400 { error: "Invalid cursor" } undecodable cursor
422 { error: "created_after must be an ISO 8601 timestamp" } bad range param

A 503 here means the feature is inert, not that the log is clean.

Alerting + self-audit

Any mismatch or stripped signature emits log.error("audit.signature_mismatch", { orgId, checked, cutover, invalidCount, invalidIds, strippedCount, strippedIds }) (first 20 ids each) so it reaches Sentry instead of living only in a response the dashboard may discard. A stripped signature raises the same alarm as a mismatch: it is the same attacker making a cheaper edit.

Each run writes its own audit.verified entry — after the page is read, so a pass never has to verify itself — with metadata { checked, ok, unsigned, legacy, invalidCount, invalidIds, strippedCount, strippedIds, cutover, createdAfter, createdBefore }. Only the first 20 failing ids go into metadata; the full list is in the response.

Known benign invalids

Rule out both of these before treating an invalid as a breach:

  • Secret rotation — below.

Note what is not on this list. DELETE /api/internal/user anonymizes the leaving user's entries with UPDATE audit_logs SET actor_user_id = NULL, keeping the trail without the PII link — and actor_user_id is a signed field, so that write used to flip every entry the user ever authored to invalid, permanently. The erasure now re-signs each anonymized row against its new contents, so a lawful erasure leaves the trail verifiable and an invalid still means what it says. A re-sign that fails is logged as internal.user.audit_resign_failed; those rows report invalid with "actorUserId": null, which is the shape to look for before calling it a breach.

It re-signs only the rows that classified ok first. This is the load-bearing half: signing whatever a row currently contains would make erasure a signing oracle. Someone with database write access but no AUDIT_SIGNING_SECRET — a leaked read-write DATABASE_URL is the realistic shape — could edit rows to erase their tracks, repoint actor_user_id at an account they control, delete that account, and have the forged contents come back ok under a real signature. A row that was invalid, stripped, or legacy before the erasure keeps its original signature and its original verdict; the count of rows skipped for that reason is logged as internal.user.audit_resign_skipped. A non-zero preserved there means the trail was already broken before the erasure touched it, which is worth reading as a finding in its own right.

Note what is not on this list any more: rows signed before the canonicalization cutover. Those land in legacy, never in invalid, so a deployment that has had AUDIT_SIGNING_SECRET set since signing shipped no longer sees its whole history reported as tampered.

Anything left over is a row that changed outside the app. Compare the invalid ids against the audit.verified metadata of earlier runs to date the change.

Reading a report

  1. stripped non-empty → treat as a breach signal, same as invalid. Nothing in the app clears a signature.
  2. invalid non-empty → rule out the two benign causes above, then investigate.
  3. legacy / unsigned are unverifiable, not clean. If either is large, scope the sweep with created_after=<cutover> to get a report made only of rows that can actually be checked — the same trick the rotation section describes.
  4. legacy should only ever shrink as retention prunes old rows. If it grows, either the cutover is set later than this deployment actually cut over, or something is writing rows the current signer did not produce.

The blind spot to know about: for a pre-cutover row whose metadata has two or more keys, "edited" and "old key order" are the same observation, so a genuine edit to such a row reports as legacy. That window is closed only by time — every row written after the cutover is fully checkable.

Rotating the secret

Rotating AUDIT_SIGNING_SECRET makes every previously-signed row report as invalid — their HMACs were derived from the old key and cannot be reproduced. There is no re-sign path, and adding one would defeat the purpose: anything that can re-sign rows can also launder an edit.

So: rotate only when you accept that everything written before the rotation becomes unverifiable, and record the rotation timestamp. created_after=<rotation time> then scopes a verify run to the rows that can still be checked, and a later invalid spike can be read against that date instead of being confused with the rotation.

Moving AUDIT_SIGNING_CUTOVER to the rotation instant is still worth doing — it stops a row written after the rotation with a NULL signature from reading as stripped. But it does not keep the pre-rotation rows out of invalid, and it is important not to expect that it will. The legacy bucket excuses one specific thing: a signature the old canonicalization cannot reproduce, which legacyOrderLost detects by finding an object with two or more keys in the row's metadata. A row orphaned by a secret rotation whose metadata is NULL or single-key — user.sessions_revoked, anything with just { via } — re-derives cleanly under the new key, mismatches, fails the legacyOrderLost test, and lands in invalid no matter where the cutover sits. (org.deleted used to be in that list and no longer is: since the billing-aware delete it carries eight metadata keys, so post-rotation it takes the legacyOrderLost path into legacy instead. Rows written before that change still classify as invalid.) That is by design: legacy is a per-row test precisely so it cannot become a blanket hiding place for everything old.

The practical consequence: after a rotation, expect a permanent invalid population over pre-rotation history, and scope verify runs with created_after rather than trying to tune it away. Only created_after actually separates the two.

Time zones

The route re-reads created_at as UTC text (to_char) and never selects the driver's Date at all. created_at is timestamp (no time zone); parsed in a process running off UTC it would shift every row and the entire page would read as tampered.

The same UTC text is what the response reports and what next_cursor is built from. It has to be: the cursor seeks the next page against the unshifted column, so a cursor built from a shifted Date would step an offset's worth of rows past where page 2 should start — silently, while checked/ok still tallied as though the sweep were complete.

Indexes

  • (org_id, created_at) — powers the dashboard timeline query.
  • (action) — filter by action type.

Retention

audit_logs rows older than 365 days are pruned nightly by /api/cron/retention. Tune AUDIT_LOG_RETENTION_DAYS (or edit the cron) if compliance requires longer retention. There's no archive-to-blob path yet — open follow-up for high-volume orgs.