Praneet Sah
Compliance

SOC 2 audit logging for developers

What SOC 2 actually expects from an audit log — which events to record, what each entry needs, why append-only storage is the whole point, and the mutable-table mistake that quietly voids the control.

What SOC 2 is actually asking for

The audit logging conversation usually starts badly, because the requirement arrives as a line in a security questionnaire — "does the system maintain audit logs of security-relevant events?" — and the engineer reading it already has logs. Gigabytes of them. Request logs, error logs, a stack trace from last Tuesday. So the answer feels like yes, and then the auditor asks a question those logs cannot answer.

The question is always some version of the same thing: who did what, to which record, when, and did it succeed? Show me everyone who accessed this customer's data in the last ninety days. Show me when this user was promoted to admin and who promoted them. Show me the export of that table and who triggered it.

Application logs cannot answer that. They exist to help you debug, they are shaped around requests rather than around actors and resources, they are noisy in the wrong places and silent in the right ones, and they rotate away on a schedule chosen for disk cost. An audit log is a different artifact with a different purpose: it is a record you keep in order to be able to reconstruct the security-relevant history of the system, later, under scrutiny, possibly by someone who does not trust you.

The other half of the misreading runs in the opposite direction. Having been told the logs are insufficient, teams sometimes conclude they must now log everything — every read, every page view, every field access — and end up with a system that is expensive, slow, and so noisy that the interesting events are unfindable. SOC 2 does not ask for that. It asks for security-relevant events. The work is deciding which events those are and then recording them properly.

What counts as a security-relevant event

There is no official list, because the criteria describe outcomes rather than enumerating events. But the set that auditors consistently care about, and that any reasonable risk assessment lands on, clusters into four groups.

Authentication events. Successful logins, failed logins, logouts, password changes, password reset requests and completions, MFA enrollment and MFA removal, session revocation, and the creation or use of API keys and service tokens. Failed logins matter as much as successful ones — a burst of failures followed by a success is the shape of a credential-stuffing attack, and you can only see that shape if you recorded the failures.

Authorization and permission changes. Any change to who can do what: a role assignment or removal, a permission grant, an invitation to an organization, an account being added to or removed from a workspace, a change to a sharing rule. These are high-value entries because they are the events that expand the blast radius of a future incident, and they are the ones an auditor will most reliably ask you to produce.

Data access and export. Not every read — reads of sensitive data. If your product handles customer records, health information, financial data, or anything a breach notification would be written about, then bulk reads, report generation, search across other people's records, downloads, and API pulls of that data belong in the log. The distinguishing test is whether you would want to know afterwards. If a support engineer opens a hundred customer records in an hour, that is something you want the ability to discover.

Administrative and configuration actions. Impersonating a user, changing security settings, rotating or disabling keys, modifying retention or logging configuration itself, provisioning and deprovisioning accounts, and any privileged action taken through an internal admin tool. Internal tools are the most commonly missed category by a wide margin: teams instrument the customer-facing application carefully and then run an admin panel that can do anything to anyone and writes nothing down.

What deliberately stays out: ordinary product activity that has no security meaning. A user editing their own draft, a page view, a background job processing a queue, a cache miss. Those belong in product analytics and application logs, and putting them in the audit log makes the security-relevant entries harder to find without making any auditor happier. The useful filter is to ask, for each event, whether you would want it in front of you while reconstructing a breach. If the answer is no, it is not an audit event.

One more that belongs in every one of those groups: denied attempts. An authorization check that returns 403 is a security-relevant event. A log that only contains successes tells you what happened but never what someone tried.

What each entry needs

A usable audit entry is a sentence with no missing words. Structurally, that means six fields.

Timestamp. In UTC, stored with the precision your database supports, generated server-side. Never take a timestamp from the client.

Actor. The stable identifier of whoever caused the event — a user ID, a service account ID, or an explicit marker for the system itself. Store the ID, not the email, because emails change and a log that says jane@old-domain.com two renames later is a puzzle. If the action was taken through impersonation, record both the impersonated user and the real operator behind them; that pair is what makes support tooling defensible.

Action. A stable, enumerated verb — user.role.granted, record.exported, auth.login.failed — not a free-text sentence. Free text is unqueryable, drifts as engineers reword it, and makes "show me every permission change last quarter" a full-text search instead of a filter.

Resource. What the action was performed on, as a type plus an identifier: customer:8842, report:311. If the entry has an actor and a verb but no object, it cannot answer the question the auditor is actually asking.

Outcome. Success or failure, plus a reason on failure. This is the field most often omitted and the one that makes the difference between a log that documents activity and a log that documents security.

Context. Enough surrounding detail to reconstruct the event without guessing — request or trace ID, source IP, user agent, and for changes, what changed. Store the before-and-after of the fields that moved, not the whole record. Be deliberate here: this field is where sensitive data leaks into a log that is retained far longer than the data itself. Never write credentials, tokens, or full sensitive records into it.

Append-only is the whole point

Here is the mistake that quietly voids the control, and it is common enough that it deserves its own section.

A team builds the audit log as an ordinary table. The application's database user has full read and write access to it, because that user has full access to everything. The admin panel can query it. An engineer with production access can update it. It is, in every technical sense, an ordinary table that happens to contain audit records.

The problem is not that anyone will actually tamper with it. The problem is that the control's entire value comes from the guarantee that nobody can — and if the same account that performs privileged actions is also able to edit the record of those actions, the log proves nothing about that account. An attacker who compromises an admin session, or an insider covering their tracks, can rewrite history. The auditor's question is not "did you tamper with it" but "what prevents tampering", and "we chose not to" is not a control.

So the property to build for is immutability. In Postgres, that starts with privileges: the application role gets INSERT and SELECT on the audit table and nothing else — REVOKE UPDATE, DELETE — so ORM-level mistakes and injected statements cannot modify entries. Add a trigger on UPDATE OR DELETE that raises an exception, so the guarantee holds even if privileges drift during a migration. Understand the limits honestly: a superuser, or the table owner, can undo both, so the real boundary is that no human and no application role holds those credentials in normal operation, and separately, that a copy of the log is shipped somewhere outside the database's own blast radius. Streaming entries to append-only object storage or a write-once log store on a schedule is what makes the log survive the compromise of the system it describes.

Two implementation details make the difference between a log that is trusted and one that is merely present. Write the audit entry in the same transaction as the change it records, so it is impossible to have a permission change without the corresponding entry, or an entry describing a change that rolled back. And write it from one path — a single logging function every mutation route calls — rather than scattering calls across handlers, because a scattered implementation is one where the honest answer to "does this cover every mutation" is that nobody knows.

Retention, without the made-up number

People want a number here, and the number does not exist. SOC 2 does not state a retention period for audit logs. It expects you to have a policy, for that policy to be reasonable given your risk, and for you to demonstrably follow it.

What actually determines the period: the observation window of the examination you are pursuing, since a Type II report samples entries across a period commonly running from three to twelve months and you cannot be sampled on history you deleted; any other regime you are subject to, several of which do state durations; and your customer contracts, which frequently impose their own floor. Pick a period from those inputs, write it into policy, and then make sure your infrastructure honors it — because the failure mode that actually gets flagged is not a short retention period, it is a policy claiming one duration while a lifecycle rule silently expires data at another.

There is a second, quieter retention question that catches teams out: the audit log itself contains data about people, and in the context field it can easily contain data belonging to them. If you are also subject to deletion obligations — a user exercising a right to erasure, a customer offboarding and asking for their data to be removed — then a genuinely immutable log and a deletion request are in tension. The way out is to design the entry so it never needs to be deleted: identifiers rather than personal details, references rather than copies, and no sensitive payloads in context. Decide that on day one, because resolving it later means rewriting a table you deliberately made impossible to rewrite.

What this looks like when it is done

You can produce, on demand, a filtered list of every security-relevant event touching a given user or record within your retention window, with actor, action, resource, outcome, and timestamp, from a store that the application cannot rewrite. You can explain in one paragraph which events are captured and why, and point at the single code path that captures them. That answers the questionnaire, and more usefully, it is what you will want during the incident that makes you glad you built it.

Audit logging is one control among several that are cheap to design in and expensive to retrofit — the same is true of RBAC, SSO, and data residency, which is the argument for building compliance-ready architecture from the start. And if you are handling health data as well, the audit log is one of the places SOC 2 and HIPAA overlap most cleanly, though they diverge sharply elsewhere: that boundary is mapped in SOC 2 vs. HIPAA.

Frequently asked

Do I need a dedicated logging service or can Postgres work?
Postgres works, and for most startups it is the better answer. What matters to an auditor is not the product name but the properties: entries are written for every security-relevant event, they cannot be edited or deleted by the application or by an ordinary operator, and you can produce them on request. A dedicated append-only table with revoked UPDATE and DELETE privileges, written inside the same transaction as the change it records, satisfies that. A dedicated service earns its place when you need retention beyond what your primary database should hold, when you want the log outside the blast radius of a database compromise, or when a customer specifically asks for write-once storage. Start in Postgres, move the archive out later.
How long do SOC 2 audit logs need to be retained?
SOC 2 does not specify a number. This surprises people who expect a regulation-style rule, but the Trust Services Criteria describe outcomes, not durations — the retention period is whatever your own written policy says, and the auditor tests whether you actually follow it. In practice the period is driven by three things: the observation window of a Type II examination, which is commonly three to twelve months, so you need at least that much history to be sampled; any other framework you are subject to that does state a number; and customer contracts, which often set their own floor. Pick a period you can defend and genuinely meet, write it down, and enforce it in code. A policy claiming a long retention that your infrastructure quietly rotates away sooner is worse than a shorter policy you actually honor.
Does this apply during Type 1 or only Type 2 audits?
Both, but they test different things. A Type I examination looks at whether the control is designed appropriately as of a point in time, so the audit log needs to exist, be wired to the right events, and be structurally protected from tampering — but no history is required. A Type II examination looks at operating effectiveness across an observation window, so the auditor samples actual entries from actual dates and checks that the control ran consistently the whole time. That is why the log has to be turned on well before you plan to start a Type II: you cannot backfill evidence of a control operating over a period during which it was not operating.

Have a project like this?

Book a call

Praneet Sah

Independent app developer. Builds full-stack products end to end — web, iOS, Android, AI agents, telecom — and has shipped every project referenced on this page personally.