Most systems have logs. Few have an audit record. The difference shows during an incident, when someone asks "who changed this, and were they allowed to" and the answer is a week of joining timestamps across five tools. An audit record is designed in advance to answer that question in one query.

What an audit record is for

Application logs exist for the engineers who run a service. They are verbose, loosely structured and safe to drop after a few weeks. An audit record exists for someone who was not there: an investigator, an auditor, a customer's security team. That reader changes the requirements.

  • It must be complete for the events it covers. A missing entry is worse than a missing log line, because absence is read as "nothing happened".
  • It must be structured. Every entry has the same fields, so it can be filtered without parsing prose.
  • It must be trustworthy. A reader needs a reason to believe that entries were not edited or removed.

The fields every entry needs

A useful entry answers six questions.

Who

The actor, as a stable identifier and a type: a person, an application, a service or an AI agent. Store the immutable id as well as the display name. Email addresses change.

What

The action, from a controlled vocabulary. A two-part form such as user.created or role.granted works well, because the first part gives you a family to group by.

On what

The resource the action touched, again as a type and an id: the user who was modified, the role that was granted, the server that was reached.

On whose behalf

This is the field most often missing, and the one that matters most once software acts for people. A support engineer impersonating a customer, a service calling another service for a user, an agent booking travel for an employee: in each case there are two identities, and the record needs both.

OAuth token exchange (RFC 8693) has a shape for this. The token's subject is the person being acted for, and an act claim names the party doing the acting. An audit entry can mirror it directly.

From where

The source: IP address, device or workload identity, and the component that handled the request. In a layered system, record which layer made the decision. "Refused" means different things at the sign-in page, at an API gateway and in the kernel of a host.

Outcome

Allowed or refused, and if refused, why. Refusals are the entries people forget to write and the ones investigators most want. A burst of refusals before a success is often the whole story of an attack. Record what the gate wanted as well as the verdict: the missing permission, the missing factor, the expired grant.

Put together, an entry looks something like this:

{
  "seq": 48213,
  "time": "2026-06-09T08:14:02Z",
  "action": "role.granted",
  "actor": { "type": "agent", "id": "agt_7f3a", "name": "provisioning-bot" },
  "on_behalf_of": { "type": "user", "id": "usr_19c2" },
  "resource": { "type": "role", "id": "billing-admin" },
  "source": { "ip": "10.4.2.17", "layer": "tool-gateway" },
  "outcome": "refused",
  "required": "manage:roles",
  "prev_hash": "9b1e...",
  "hash": "c27a..."
}

Leave out secrets, token values and request bodies. An audit record that contains credentials becomes a target itself.

Making it tamper-evident

"Immutable" is a strong word for a row in a database that an administrator can update. What you can achieve in practice is tamper evidence: a change or a deletion becomes detectable.

The standard technique is a hash chain. Each entry stores a hash computed over its own content and the hash of the entry before it. Altering an old entry changes its hash, which no longer matches what the next entry recorded, and the mismatch carries forward to the end of the chain. Removing an entry leaves a gap in the sequence numbers.

Three practical notes:

  1. Verification must actually run. A chain nobody recomputes is decoration. Run verification on a schedule and show the result where people will see it.
  2. Anchor the head elsewhere. Someone who can rewrite the whole chain can produce a consistent forgery. Publishing the latest hash to a system under separate control, at intervals, closes that gap.
  3. Distinguish "broken" from "could not check". A verification that timed out has not found tampering. Reporting the two as the same state trains people to ignore the warning.

Making it searchable

A record that is complete and trustworthy still fails if nobody can find anything in it.

  • Index by the questions people ask. Actor, resource, action and time cover nearly every investigation. "Everything this person did" and "everything done to this resource" should each be one filter.
  • Separate human events from machine events. In a system that records datapath decisions, machine events outnumber administrative ones by orders of magnitude. Rendered newest first with no grouping, the sign-in you came to find is thousands of rows down. Offer lenses, such as people and admin actions in one and datapath decisions in another.
  • Build the lenses from the data. Derive the list of actions from what the record actually contains. A hardcoded list drifts the moment a service adds a new verb.
  • Export it. Security teams already have a log tool. Deliver the record there as it is written, in a documented schema, and treat the export as part of the product.

Recording agent actions as well as people's

An AI agent that calls tools generates the same kinds of event as a person, at a higher rate and with one more layer of indirection. Three rules keep the record useful.

  1. Give the agent its own identity. If an agent signs in with a person's credentials, the record cannot tell them apart. See what is AI agent identity.
  2. Record the delegation on every entry. The agent as actor, the person as the party acted for.
  3. Record approvals as events. When a person approves or refuses an agent's request, that decision is an entry with its own actor.

The goal is one record, not an "AI log" beside the real one.

How AuthFI does it

In AuthFI, sign-ins, grants, approvals and refusals for people and AI agents land on one record per project. Entries are chained, and the header of the Audit page reports the state of the chain before the first row: altered, intact, intact with some events not covered, or could not verify, with an option to check again.

Each row shows what happened, who did it, on what, where it was refused and what that gate required, whether the entry is covered by the chain, and when. Refusals name the layer that made them: at sign-in, at the tool gateway, at the service proxy or on the host.

The page groups events into three lenses built from the actions the project actually has: people and admin, AI response, and datapath. It can be narrowed by exact actor and resource. For agents, the console shows on whose behalf each one acts, and every verdict at the tool gateway is recorded with its reason.

The record can be sent to the log tools you already use. Compliance reports are in development. More is on the security and audit page.

Key takeaways

  • An audit entry answers who, what, on what, on whose behalf, from where and with what outcome.
  • Record refusals with the layer that refused and what it required. They are the entries investigators need most.
  • Chain entries so edits and gaps show, run the verification, and keep "could not verify" distinct from "tampered".
  • Separate human and machine events, or the human ones will be buried.
  • Put agents on the same record as people, with the delegation on every entry.