System designCore14 min

Audit logging

When money goes to the wrong account, the question is not whether the server was healthy but who changed what, when, from where, and whether that record can be trusted. An audit log is written for that question: it commits with the change it describes, it can't be quietly edited, and it is kept long enough to be read after the fact.

1 · The problem

An app log says the server was busy; an audit log says who changed the bank account

A payroll service runs pay for a few thousand small businesses. On payday, Dana, a barista at one café, gets nothing: the pay went to a bank account Dana has never heard of. The café owner says nobody there changed it. Support opens the logs and finds one line from three weeks ago, PUT /employees/88 200 41ms, with no user, no old value and no new value.

That line was written to debug the service, not to answer a question about responsibility. An audit log (or audit trail) is a separate record of security-relevant actions, kept so that someone can later reconstruct who did what, in what order, and prove it. The difference shows in every property:

Aspect

Operational log

Audit log

Written for

Debugging and performance

Reconstructing attributable actions later

Contains

Requests, errors, timings

Who, what, to what, when, where, outcome

Kept for

As long as it helps debugging

A required retention period, then deleted

Who can change it

Often the team that runs the service

Nobody; tampering must be detectable

Audit trails are collected for different purposes than other logs and belong apart from them, and plain statement logging isn't one either: a list of every operation is not enough, because an auditor has to be able to find the statements that matter. Metrics, traces and debug logs live in Observability: metrics, logs & traces; this page is about the audit trail.

2 · What to record

Each record answers who did what, to what, when, where, and with what result

An audit record must establish what type of event occurred, when, where, its source, its outcome, and the identity of every person or system involved. For the payroll service, the bank change would have looked like this:

One audit record

json
{
  "event_id": "9f1c2e7a",
  "time": "2026-09-11T09:05:12Z",
  "actor": {"type": "staff", "id": "support/sam.lee", "ip": "203.0.113.42"},
  "action": "employee.bank_account.update",
  "target": {"business": "cafe-412", "employee": "88"},
  "change": {"account_last4": ["3317", "9054"]},
  "outcome": "success",
  "request_id": "req-71b0"
}

With that record, support answers in a minute: a support account, from an unfamiliar address, changed the account ending 3317 to one ending 9054. The request_id ties the record to the request's other logs and traces. The event is a named business action, not an HTTP route, so a search for every bank change across all customers is one query.

Which events deserve a record is a decision made at design time, in proportion to the risk. Some events should always be logged:

Event

In the payroll service

Sign-ins and access-control failures

A support agent's sign-in, failed or not; a refused request for another business's data

Administrative actions

Adding staff users, changing privileges, anything done with a shared break-glass account

Access to sensitive data

Opening an employee's bank or tax details

Data imports and exports

A payroll export, including a report shown on screen

Key use and configuration changes

Rotating the key that encrypts bank details; changing a payment setting

Just as important is what stays out. Some data should never go into logs as it is, but be removed, masked, hashed or encrypted first: passwords, session identifiers, access tokens, connection strings, encryption keys, bank account and card data, and sensitive personal data. That's why the record above keeps only the last four digits. An audit log full of secrets becomes the next thing to steal (Secrets management).

The input has to be cleaned too. Input containing a line break can add what looks like a second, false record to a text log, covering an attacker's tracks; this is log injection, and the fix is to encode carriage returns, line feeds and delimiters, or to write structured records like the one above. Clocks on every server must be synchronized, or the order of events across machines can't be trusted (Time, clocks & ordering).

3 · The write

An audit record must commit together with the change it describes

The simple design saves the new bank account, then sends an audit event to a logging service. If the process crashes between the two steps, or the logging service is down, the change happened and no record of it exists. Reverse the order and a crash leaves a record of a change that never happened. Either way the trail lies, at the moment it matters most.

The fix is the same one used for any event that must match a database write: insert the audit record into a table in the same database transaction as the change, and let a separate relay copy it to the audit store afterwards. This is the transactional outbox, explained in Distributed transactions: 2PC & sagas:

The change and its record commit atomically. The relay may send a record twice after a crash, so the audit store drops any event id it has already stored.

Some teams capture the same information from the database's change stream instead (Change data capture). That records every row change, even ones made by hand outside the application, but a row change knows less than the application: which person, from which address, through which support ticket. The two are often combined.

Then decide what happens when audit logging fails. The usual control is an alert to named people within a set time, plus an action chosen in advance: overwrite the oldest records, stop generating records, or shut the system down. The right answer differs by action. In general a logging failure shouldn't stop the application; the payroll service makes high-risk changes the exception: with the record in the same transaction, a bank change that can't be audited doesn't happen at all.

4 · Tamper evidence

A log its subjects can edit proves nothing, so separate it, chain it and lock it

Suppose sam.lee's account belonged to an insider who also had write access to the audit table. Deleting one row would erase the evidence. The standard controls address this in layers:

  • A separate system: store audit records on a different system from the one being audited, so compromising one doesn't compromise both.
  • Separate privileges: only a small set of people may manage audit logging, distinct from those who administer the system being audited.
  • Cryptographic integrity: signed hashes, so anyone with the public key can check the records.

A hash chain makes edits detectable. Each record stores a hash of the previous record's hash together with its own content, so every record depends on all the ones before it. Changing an old record changes its hash, and the next record's stored value no longer matches:

#1 sign-inhash 5a38869b#2 bank changeprev 5a38869b#3 sign-outprev e75d073eSigned digestlatest hash 764ec9c2hash feeds nexthash e75d073esigned hourly

Each hash is SHA-256 of the previous hash and the record. Change record 2's employee from 88 to 41 and its hash becomes deffdd43, which no longer matches the e75d073e stored in record 3.

The chain behind the figure

python
import hashlib
prev = "00000000"
for r in records:
    h = hashlib.sha256((prev + r).encode()).hexdigest()
    print(r, "prev", prev[:8], "hash", h[:8])
    prev = h

A chain alone has one gap: cutting off the newest records leaves a shorter chain that still checks out. So the latest hash is signed and published somewhere the log's writer can't rewrite. AWS CloudTrail does exactly this: it hashes every log file with SHA-256, and every hour delivers a digest file listing the last hour's files and their hashes, signed with a private key. Each digest also contains the previous digest's signature, so the digests form a chain of their own.

Validation then shows whether a file was changed or deleted after delivery, and can also confirm that no log files were delivered during a period, which is how a missing hour is told apart from a quiet one. Chaining detects tampering; it doesn't prevent it. Prevention comes from WORM (write once, read many) storage, such as Amazon S3 Object Lock:

S3 Object Lock mode

Who can delete before the retention date

Governance

Only users granted a special permission to bypass it

Compliance

No user, including the account's root user; the period can't be shortened

Legal hold

Nobody while it is on; it has no end date, and users with the legal-hold permission can remove it

5 · Retention

A log you didn't keep, or didn't turn on, can't be read later

Café 412's pay was diverted three weeks after the change. If the payroll service had kept its audit records for 14 days, the trail would already be gone. Investigations start late: this change was found on payday, three weeks after it was made. Cloud providers' own defaults show how different the answer can be by log type:

Google: data access logs
30 days
AWS: CloudTrail history
90 days
Google: admin activity
400 days
US agencies: required
913 days

Google Cloud keeps admin activity audit logs 400 days and other logs 30 days by default; CloudTrail's built-in history covers 90 days of management events. US federal agencies must keep certain audit logs 12 months in active storage plus 18 in cold storage, about 913 days in all.

The cloud defaults are a floor, not a policy. Google writes admin activity logs always, and they can't be disabled; data access logs, which record who read what, are off by default for most services because of their volume. AWS keeps 90 days of management events automatically; anything longer needs a trail you create, and data events, such as reads of individual stored objects, are not logged by default even then. The data you most need in a breach is often the data you had to switch on.

The 2023 intrusion into Microsoft's hosted email showed why that matters. After learning of the breach, the US Commerce Department, one of the victims, asked Microsoft for logs, and Microsoft switched on the premium logging. Commerce still could not see past activity: the logs captured data only from the moment the advanced logging was enabled. A log turned on after the incident starts recording after the evidence.

From the review board's account: the provider notified Commerce on June 23, 2023, of access that began on June 6. The provider shared some data, but the detailed log recorded only from the moment it was enabled, leaving Commerce an incomplete picture of the earlier activity.

Retention cuts both ways. Logs must not be destroyed before the required retention period, and must not be kept beyond it. Audit records name people and their actions, so they carry the same privacy duties as other personal data (Privacy, residency & retention). Old records can move to cheaper, slower storage tiers (Data lifecycle: tiering, TTL & compression), as long as they can still be searched within the time an investigation needs.

Reading the audit log is itself an action worth auditing. All access to logs should be recorded and monitored, and the privilege to read them restricted and reviewed periodically. Support staff who can search every customer's bank changes hold a lot of personal data in one query box.

6 · Detection

A log nobody reads catches nothing; alerts turn records into detection

The same 2023 intrusion was first noticed by the US State Department. Its security team had written a custom alert rule over Microsoft's mailbox-access log, a log it could read only because it had bought the premium license that includes enhanced logging. On June 16, 2023, the rule fired; the team investigated, judged the activity malicious, and contacted Microsoft that day.

Few, if any, other victims had the same logging, and buying it would not have been enough on its own: few organizations analyzed that very large log in detail. The lesson drawn afterward was that cloud providers should give customers audit logs of all access to their data by default, at no extra charge, kept for at least six months.

For the payroll service, detection means turning the audit store into a feed for rules that a person responds to:

Audit storeappend-only, WORMDetection rulesbank change patternsAlertwithin minutesSecurity on-callhold the payment runevery new recordmatchpageAudit storeappend-only, WORMDetection rulesbank change patternsAlertwithin minutesSecurity on-callhold the payment runevery new recordmatchpage

Rules for this example: a bank change by staff rather than the employer, several changes from one address, or a change shortly before a payment run. A record seen within minutes can stop the payment; one found on payday only explains it.

OWASP's 2025 Top 10 ranks security logging and alerting failures ninth and lists the usual gaps: auditable events such as failed sign-ins or high-value transactions not logged, logs not protected from tampering, logs only stored locally, logs not monitored, and alert thresholds that nobody responds to in time. Every one of them would have left the café's question unanswered.

7 · The checklist

Audit logging is a design decision, made before the first incident

For the payroll service, and most systems that act on other people's money or data:

Decision

Payroll service choice

Which events

Sign-ins, privilege changes, bank and pay changes, exports, staff access to customer data

Which fields

Actor, action, target, time, source address, outcome, request id; secrets masked

How it's written

Same transaction as the change, through an outbox; duplicates dropped by event id

If logging fails

Alert; high-risk changes are refused rather than made without a record

Tamper evidence

Separate account, few administrators, hash chain, signed digests, WORM storage

Retention

Set by law and contract, long enough for late discovery, then deleted

Use

Detection rules with an owner, and audited access to the log itself

Audit logging and event sourcing overlap but aren't the same: an event-sourced system stores its state as a history of events, which gives a natural record of what changed (Event sourcing & CQRS). It still needs the who, from where and with what authority, and the history still needs protecting from the people it describes.

Check yourself

4 questions

1. The payroll API saves a bank change, then calls the logging service to record it. What is the weakness?
2. Audit records are hash-chained, each holding the previous record's hash. An attacker deletes the last 50 records. Does the chain reveal it?
3. After a breach is discovered, the team enables detailed data-access logging. What will it show about the breach?
4. Which item belongs in an audit record of a bank-account change?

Takeaways

Remember this

  • An audit log records who did what, to what, when, from where and with what result, so actions can be reconstructed and attributed later.
  • Write the audit record in the same transaction as the change, through an outbox, so neither can exist without the other.
  • Store audit logs apart from the system and people they describe, chain and sign them to detect tampering, and use WORM storage to prevent deletion.
  • Keep audit logs for the required period and no longer; a log enabled after an incident can't show what happened before.
  • Logs only detect anything when rules read them and someone answers the alert.