Picture a finance lead who opens a ticket on Monday morning: a supplier payment went out on Friday, nobody remembers approving it, and the amount looks wrong. Engineering opens the audit log. It says payment.updated, a timestamp, and a user ID. That is all. The log exists, it is full of rows, and it cannot answer a single question anyone is asking.
Most audit logs fail this way. They were built to satisfy a checkbox, so they record that something happened rather than what, by whom, and why it was allowed.
Start with the questions, not the events
An audit log is read under pressure, usually by someone who is not an engineer, usually after something has gone wrong. So design it backwards from the questions that person will ask:
- Who did this, and were they acting as themselves or on behalf of someone else?
- What exactly changed, from what value to what value?
- When did it happen, and in what order relative to other changes?
- Where did the request come from?
- Which rule or permission allowed it?
- Was it a person, a scheduled job, or an integration?
If an entry cannot answer all six, it will eventually fail you. Write these questions on the wall before you design the schema. They make most arguments about what to log short.
The shape of a useful entry
A good entry is a small, boring, consistent record. Here is roughly what we would expect to see:
{
"id": "01J...",
"occurredAt": "2026-05-22T14:03:11.482Z",
"actor": { "type": "user", "id": "u_4821", "onBehalfOf": null },
"action": "payment.approve",
"target": { "type": "payment", "id": "pay_9910" },
"before": { "status": "pending", "amount": 450000 },
"after": { "status": "approved", "amount": 450000 },
"context": { "ip": "203.0.113.7", "session": "s_77a", "requestId": "r_c31" },
"authorisation": { "role": "finance_approver", "policy": "dual_approval" }
}
A few choices in there are deliberate.
The actor has a type. Users, service accounts and scheduled jobs behave differently, and "who" is meaningless if you cannot tell a human from a cron job. The onBehalfOf field matters for support impersonation and delegated approvals, where the person clicking is not the person accountable.
The action is a verb in a stable vocabulary, not a free text string. payment.approve can be counted, filtered and alerted on. "User approved a payment, I think" cannot.
The before and after values are the part teams skip because they are awkward. They are also the part that settles disputes. Storing only the new value tells you what the record is now, which you can already see. The diff is the evidence.
The authorisation field records why the action was allowed at the moment it happened. Roles change. If someone loses their approver role next month, you still need to know they held it on Friday.
What not to record
More is not better. Two categories of data do damage when they land in an audit log.
First, secrets. Passwords, tokens, full card numbers and raw identity documents do not belong in a log that many people can read and that you are required to keep for years. Record that a credential was changed, never the credential. For personal data, store references and hashes where you can, and be deliberate about anything that would make the log itself a data protection liability. Under the Nigeria Data Protection Act 2023 and under GDPR, a log full of personal data is still personal data.
Second, noise. Logging every read, every page view and every health check buries the entries that matter. Reads of sensitive records are worth logging, such as someone opening a customer's full file. Reads of a dashboard are not. Decide by asking whether a regulator, an auditor or an angry customer could plausibly ask who looked at this.
Make it hard to change
An audit log that administrators can edit is a diary. The value of the record comes from the fact that nobody, including the people who run the system, can quietly rewrite it.
There are a few practical levels of protection, and you do not need all of them on day one:
- Application code can only insert, never update or delete. Enforce this with database permissions, not good intentions.
- Each entry includes a hash of the previous one, so a gap or an edit breaks the chain and is detectable.
- Logs are copied to a separate store with different credentials, so compromising the application database does not let someone clean up behind themselves.
Write the entry in the same transaction
The most common technical flaw we see is logging after the fact. The business change commits, then the application tries to write the audit entry, and the second step fails or the process dies between them. You now have a change with no record.
Write the audit entry in the same database transaction as the change itself. Either both exist or neither does. If the log lives in a different system, use an outbox table inside the transaction and ship from there. It adds a little plumbing and removes an entire class of "we lost the evidence" incidents.
Retention, access and the audit of the audit
Decide how long you keep entries before someone asks. Financial records often carry multi year retention expectations, and sector regulators set their own. Check the rules that apply to you rather than guessing, and write the period down.
Decide who can read the log. Access to an audit trail is itself sensitive, and reading it should be logged too. It sounds circular. It is not: the first time someone investigates a colleague, you will want proof that the investigation was itself authorised.
Finally, test the log the way you would test a backup. Once a quarter, pick a real event and try to reconstruct it from the log alone, without asking an engineer. If a finance lead can answer the Monday morning questions from the screen, the log works. If they need a developer and a database console, it does not yet.
A small place to begin
If your current log is a thin list of event names, you do not need a rebuild. Add actor type, before and after values and the request ID to your most sensitive action, probably payments or permission changes. Write it in the same transaction. Ship that one action well, and use it as the template for the rest.
