Platform Fee
4% Markup
Platform Fee
0% Tier 3
Building a Tamper-Evident Audit Trail for Admin Actions
"Who rotated that key?" should be a query, not a guess. Here is how to build an audit trail for an LLM gateway with complete attribution, consistent IP capture, and tamper-evidence auditors trust.
When a key gets rotated, a member gets removed, or a budget gets raised, someone will eventually ask "who did that, and when?" If the answer is "let me check the application logs and guess," you don't have an audit trail — you have hope. A real audit trail makes that question a single query with a complete, trustworthy answer. This post is how to build one that survives an auditor's skepticism.
What belongs in the trail
An audit trail records mutating administrative actions — the things that change security posture or money:
| Action class | Examples |
|---|---|
| Key lifecycle | created, rotated, deleted |
| Membership | member added, removed, role changed |
| Org/team | created, renamed, deleted |
| Billing | plan changed, budget set/raised |
| Settings | data policy, guardrail config changed |
What it does not record is every LLM request — that's request logging, a different (higher-volume) concern. The audit trail is for the deliberate administrative acts a human took, where intent and identity matter.
Three things every entry must carry
An audit entry is only useful if it answers who, what, and from where:
{
actor: user id + role at time of action
action: "key.rotated" (verb.object, machine-filterable)
target: the key/member/org affected
source: client IP + timestamp
context: org_id, team_id (the same UUIDs everywhere)
}Miss any of these and the entry degrades. No actor → "someone did it." No source IP → no attribution for an investigation. No timestamp → no timeline. The discipline is that every mutating route writes a complete entry.
The gap auditors find: inconsistent IP capture
The classic audit defect is one route — say, a profile update — that logs the action but forgets the client IP, while every other route captures it. Now that action has a blank attribution column, and your "100% attributed" claim is false. Capture the client IP the same way on every mutating route, through one shared helper, so a new route can't silently ship without it.
Source IP: get it right once, everywhere
Client IP is easy to get wrong (trusting a spoofable header) and easy to get inconsistently (each route rolling its own extraction). Both are defects. The fix is a single shared getClientIp helper used by every route that writes an audit entry — so the extraction logic is correct in one place and identical everywhere. Consistency here is what lets you say "every admin action is attributed to a source," and mean it.
Tamper-evidence
An audit trail an admin can quietly edit is theater. Tamper-evidence doesn't require a blockchain — it requires that the normal application surface offers no UPDATE or DELETE path to audit rows. Entries are append-only from the app's perspective: routes can insert, nothing can rewrite history. Combined with database-level access controls (the audit table isn't writable by the customer-facing role beyond inserts), this gives you the property auditors actually check — that the record of what happened can't be altered after the fact by the people it records.
Reading the trail
The audit view should let an operator answer real questions fast: filter by actor, by action type, by target, by time range. "Show every key rotation last month" or "everything user X did" should be a filter, not an export-and-grep. Access is itself role-scoped — viewing the audit trail is an owner/admin capability, and cross-org reads return nothing (the tenant isolation applies here too).
A subtle correctness point: the trail should display what actually happened, with action labels that match the verb. An action stored as profile_updated should render as "Profile updated" with the right severity tint — not fall through to a generic neutral label that makes a security-relevant change look like noise. Consistent action naming is part of making the trail readable, not just complete.
The takeaway
An audit trail earns trust through completeness and immutability: every mutating action writes an entry with actor, action, target, and a consistently-captured source IP; nothing can rewrite history through the app; and reads are role-scoped and tenant-isolated. Build it with one shared IP helper and append-only semantics, and "who did that, and when?" becomes a query with an answer you'd stake an audit on. See the SOC 2 checklist for where this fits the broader control set.