Every role change writes one row: who did it, to whom, what the role was before and after, when, and from which request. That is the first line of audit logging best practices for a SaaS, and role changes, deletions and billing changes are the 3 action families that must never happen without such a row. The log itself is append-only and readable by few.

Audit logging best practices: what an audit log is, and the seven rules for a SaaS

An audit log is a record of who did what to which record, and when, kept so an important change can be investigated later. Good practice for a SaaS is 7 rules: log sensitive actions; record actor, action, target, before and after; write in the same transaction; store no secrets; make the table append-only; restrict readers; and set retention.

NIST’s glossary entry for audit log gives the audit log definition as “A chronological record of system activities, including records of system accesses and operations performed in a given period.”, taken from NIST SP 800-53 (Revision 5) among other publications. For an app owner the meaning of an audit log is narrower and more physical: a table in the application’s own database, written by server code, kept apart from the application log.

Auditability, as a security property, is the ability to answer who did what, and when, after the fact. An audit log is one of the access controls in the authentication checklist, and it matters because important changes are difficult to investigate without a reliable activity record.

Audit versus logging comes down to three differences, in my reading. The app log is for debugging; the audit log is for accountability. The app log may be sampled and usually covers everything at low detail; the audit log records every occurrence of a short list of actions in full. The app log rotates in days or weeks; the audit log is kept for months and never edited. The app-log side of that split, with its structured fields and request ids, is covered in how to do logging. Audit logging and monitoring are different jobs too: the log records, and monitoring watches the audit table for a few rules and raises an alert when one fires.

The table below holds the audit log best practices I’d apply to any SaaS, one rule per row. The third column, how you know a rule holds, is my working rule rather than a standard.

RuleWhyHow you know it holds
Log the sensitive actions, not everythingA short list gets read and alerted on; a record of every request buries the one row that mattersEach event on your list has exactly one place in the code that writes it
Every row says actor, action, target, before, after, time, request and outcomeA row missing any of these cannot answer the question it was kept forA row picked at random tells you who, what, to which record, when, and whether it succeeded
Write the row in the same transaction as the changeThe change and its row then exist together or not at allForcing the audit insert to fail rolls the change back
No secrets and no unnecessary personal data in the valuesThe log is copied, exported and kept longer than most dataScanning before and after turns up no passwords, tokens or card numbers
The table is append-only for the applicationAn account that misbehaves cannot erase its own rowThe app’s database role gets a permission error on UPDATE and DELETE
Few people can read it, and reads are loggedThe log describes customers and staff, so reading it is itself sensitiveAn ordinary user is refused, and an admin’s read writes its own event
Retention is set and enforcedA log with no end date grows forever; one deleted early answers nothingA scheduled job removes rows past the window, and its runs are visible

For the retention window, my working rule is about 12 months as a starting point, or the contract’s term, the same line I give for how long to keep application logs.

Audit trail: what the word means, and the minimum audit trail a SaaS should keep

An audit trail is the sequence of audit log entries that lets someone reconstruct one event from start to finish: the log is the store, the trail is the story it tells. The minimum trail for a SaaS covers sign-ins, role and permission changes, deletions, exports, billing changes, admin actions and security settings.

NIST’s glossary entry for audit trail defines it as “A chronological record that reconstructs and examines the sequence of activities surrounding or leading to a specific operation, procedure, or event in a security-relevant transaction from inception to result.”, citing NIST SP 800-37 (Revision 2) and NIST SP 800-53 (Revision 5) as its sources. Financial audit, clinical records and qualitative research use the term as well, each in its own sense; this page covers only the software one.

In a SaaS, an audit trail means the entries that let you rebuild one incident in order. Take a customer whose workspace lost its data: the trail is the sign-in, then the role grant, then the deletion, each with its actor and time. My working rule for the minimum trail is seven kinds of entry:

  • sign-ins and failed sign-ins for admin accounts;
  • role and permission changes;
  • deletions and restores;
  • exports and bulk reads of customer data;
  • billing and plan changes;
  • admin actions taken on a customer’s behalf, such as impersonation and overrides;
  • changes to security settings: MFA, API keys and SSO.

The importance of an audit trail shows up in three places. Investigation: when something goes wrong, the trail says what happened and in what order. Customer trust: a larger customer’s security review may ask how admin actions are recorded, and the reports section below returns to that. Deterrence: an admin action that carries a name is one someone has to answer for.

An audit trail policy for a small team fits in one paragraph: what is recorded, who can read it, how long it is kept, who reviews it, and how often.

What goes wrong without it

Four questions a founder may be asked show the gap, and none of them needs an attacker. Take the first: a customer’s project is gone, and there is no record of who deleted the data.

The question someone asksWhy nobody can answerWhat it costs
Who deleted this project?The application log has rotated out, and it never recorded deletes anywayThe only evidence left is a backup taken before the delete
Who changed the billing plan?The customer, a teammate with admin rights, a support action or a payment webhook could each have done it, and nothing tells them apartAn account sits on the free plan while still using paid features, or the reverse, with nobody to ask
Who made this person an admin?The role column says admin, and nothing says who set itA role grant with no author turns up in the middle of an incident
Could the log have been edited?The audit table sits in the same database, with the same permissions as everything elseThe account that misbehaved could have erased its own row

In my audits, two apps kept no record of who changed what. An ops SaaS stored no updatedAt, updatedBy or history on any of its twelve record types, wrote whole items back and hard-deleted records, so a risk alert that was reviewed and closed left no trace; a CRM stamped every proposal, email, meeting and call log “Current User”.

My reading of those two: a column that records who last touched a row is not an audit log, and a system that writes “Current User” on every record has no actor at all.

Two wider numbers sit behind this. 7 of the 21 third-party apps had confirmed cross-user or cross-tenant authorization failures, where a logged-in user could read or write another customer’s data. And 17 of the 21 had no error tracking or alerting: when a user hits an error, nothing records it. Those 21 are the 11 public and 10 held-out third-party apps I audited in June and July 2026, a selected set rather than a random sample or a rate for AI-built apps in general.

My reading: an authorization failure is the case where an audit trail is what tells you how far it went. Whether your own app has the gap is for the six tests further down to decide.

How to do it: the events, the table, the protection, the reports, and the logs you already have

Five parts follow, in the order I would build them. Before any code, one check from the browser: as an admin, change a test user’s role in the app, then ask where you would look to see that it happened. If the only answer is that the user’s row now says admin, there is no audit log.

What to record: audit events for role changes, deletions and billing changes

Audit events are the specific actions that write a row. A sensitive-action log needs 3 families: role and permission changes, deletions and exports of customer data, and billing changes. Each event records the actor and its type, the action name, the target, the values before and after, the time, the request id and the outcome, including denied attempts.

An audit event is one named action: role.changed, member.removed, project.deleted, export.created, plan.changed, payment_method.updated, admin.impersonation_started. My working rule is to name them as noun.verb in the past tense and keep the names stable, because reports and alerts key on the name. The audit log events in each family differ in what before and after hold and in who the actor can be, as the table sets out; it is my design, not a standard.

Action familyEvent namesWhat before and after holdActor types to expect
Role and permission changesrole.changed, member.removedThe old and new role; the member’s role at removalAdmin, API key
Deletions, restores and exportsproject.deleted, project.restored, export.createdWhat was lost or restored (id and name); what the export coveredUser, admin, system (a scheduled job)
Billing changesplan.changed, payment_method.updatedThe old and new plan; the payment method’s type, never card dataUser, admin, system (a payment webhook), API key

The role model itself, the roles and what each may do, is the subject of RBAC examples. Deleting a whole account, the larger version of project.deleted, is a separate job: building an account deletion feature. Billing is where the audit log tracks the actor type most carefully: a plan can change because the user clicked, an admin overrode it, a payment webhook arrived or an API key called your endpoint, and the row has to say which.

The admin audit log is not a second table. It is the same table filtered to admin actors and admin routes, and every protected admin operation writes to it, admin.impersonation_started included; the protections for those routes are a separate list: the admin panel security checklist. Actors that are not people (API keys, service accounts, AI agents) are recorded by key id, never as the key’s owner, and how those callers prove who they are is covered in API authentication best practices. Denied attempts at any of these actions are events too, written with an outcome of denied.

How to implement audit logs: the table, the write path, and an audit log example in an app

Implementing audit logs means one table and one rule. The table holds actor, action, target, before, after, request id, IP address and a server-set timestamp. The rule: the server writes the audit row in the same database transaction as the change it describes, so neither can exist without the other.

Here is the table for PostgreSQL, with the grants that make it append-only for the application. occurred_at is set by the database, and a timestamptz value is stored internally as UTC. The logging guide linked above sketches the same table from the application log’s side.

create table audit_log (
  id          bigint generated always as identity primary key,
  occurred_at timestamptz not null default now(),  -- set by the database
  actor_type  text not null,   -- user, admin, system, api_key
  actor_id    text not null,
  action      text not null,   -- role.changed, plan.changed, ...
  target_type text not null,
  target_id   text not null,
  tenant_id   text,
  before      jsonb,
  after       jsonb,
  request_id  text,
  ip          inet,
  outcome     text not null    -- success or denied
);
-- run by the migration role; app_role is the role the app connects as
revoke all on audit_log from app_role;
grant insert, select on audit_log to app_role;

The write path is one helper, called by server code inside the same transaction as the change. A failed audit write then rolls the change back, and a rolled-back change leaves no row. On a stack that reaches the database through Supabase’s Data API with supabase-js, each call is its own transaction: Supabase’s API is built on PostgREST, and in PostgREST’s words “every request to an API resource runs inside a transaction”. So, in my reading, the change and its audit row go into one Postgres function called once by RPC, or into a trigger on the changed table. A PostgreSQL function runs with the privileges of the user that calls it unless it is declared security definer, in which case it runs as its owner and its search_path should exclude schemas untrusted users can write to. Once the client roles’ grants on the audit table are revoked, as the protections below advise, a function or trigger that runs as the browser’s role cannot insert the row, so in my reading either the change goes through server code or the function is declared security definer and written with that care.

Larger systems may write audit events asynchronously, through a queue. With one database and a short action list I would not: the same-transaction write costs a single insert, and a queue adds a way to lose rows (my reading). The row is never written from the browser. In the values, store ids and the fields that changed, never passwords, tokens, full card data or free-text personal data, and mask a value where one is needed for meaning.

Below the application sits a lower layer. The pgAudit extension “provides detailed session and/or object audit logging via the standard PostgreSQL logging facility”: session logging covers the statements a user runs, object logging the statements that touch chosen tables, and the entries go to the Postgres logs rather than to a table. Its README also says “Audit logging is best-effort and not transactional”, the opposite of the rule above. Why it cannot replace application events is my reading: the user it records is the database role, and a database role shared by the whole app does not name the person behind a request.

For an audit gem on Rails, the audited gem “logs all changes to your models”, and changes made within a request are attributed to the current user through the controller’s current_user method by default. The paper_trail gem tracks changes “for auditing or versioning”, stores the version of a record from before each change, and records who made it in a whodunnit column once you add the set_paper_trail_whodunnit controller callback. In my reading, both record changes to models, so a denied attempt, which changes no model, still needs a named event of its own.

The four rows below are a constructed audit log example in an app, an illustration rather than a real export; read in order for one workspace, they are also an example of an audit trail. The time and request id columns are left out to keep it narrow.

actor_typeactor_idactiontargetbeforeafteroutcome
adminusr_admin_arole.changeduser usr_b{"role":"member"}{"role":"admin"}success
adminusr_admin_aproject.deletedproject prj_launch{"name":"Launch plan"}nullsuccess
systemstripe_webhookplan.changedworkspace ws_a{"plan":"pro"}{"plan":"free","stripe_event":"evt_..."}success
userusr_cexport.createdworkspace ws_anullnulldenied

Protecting the log: who can read it, and how tampering would show

An audit log needs protecting both ways: few people can read it, and nobody can change it. The application’s database role, which must not own the table, gets INSERT and SELECT on it and no UPDATE or DELETE, reads go through an admin-only route, and a periodic copy to separate storage means a compromised database cannot quietly rewrite history.

The five protections, in the order I’d put them for a small team (my working rule):

  1. 01 The app database role has INSERT and SELECT on the audit table, no UPDATE, DELETE or TRUNCATE, and is neither the table owner nor a superuser
  2. 02 On Supabase, row-level security is on with no policies for the client roles, their existing grants are revoked, and only server code reaches the table
  3. 03 Reading the log is an admin-only route, scoped to the workspace when customer admins see their own entries, and every read writes its own event
  4. 04 A scheduled copy goes to separate storage under a different account, so a compromised database cannot rewrite history
  5. 05 An optional hash chain, where each row stores a hash of the previous row, for customers who ask for tamper evidence

The first protection depends on who owns the table. In PostgreSQL’s words, “The right to modify or destroy an object is inherent in being the object’s owner, and cannot be granted or revoked in itself”, and owners “can always re-grant their own privileges”. PostgreSQL’s GRANT reference adds that “database superusers can access all objects regardless of object privilege settings.” So a migration role creates the table and the app connects as another role.

On Supabase, server code using the secret key acts as service_role, which Supabase’s row-level security guide describes as “Full access. It bypasses RLS, so keep it server-side”. Grants and policies are separate checks in that guide: grants decide whether a role can run an operation on the table at all, policies decide which rows. Bypassing RLS is not bypassing grants, so in my reading service_role is the app role in the SQL block above: all revoked, then only INSERT and SELECT granted back. The same guide says that once RLS is on, “no data is accessible through the API when using a publishable key, until you create policies”, and it advises: “Revoke any existing grants from both client roles.” It also notes that “A secret key bypasses RLS only when the request carries no user access token”, so a server client that forwards the user’s session writes as that user and, with the revokes above, is refused (my reading).

The hash chain works like this: each new row stores a hash computed over its own contents and the previous row’s hash. Editing or deleting any row breaks every hash after it, so a check that recomputes the chain shows where the history changed. It adds work on every insert, so I’d add it only when a customer asks for tamper evidence.

OWASP’s Logging Cheat Sheet asks for the same things in general terms: stored log data protected from “unauthorized access, modification and deletion”, tamper detection “so you know if a record has been modified or deleted”, and “All access to the logs must be recorded and monitored”. Account recovery actions, such as a reset done by support or a changed recovery method, are high-value entries here; why recovery that leans on what a user knows is weak is covered in examples of security questions and why they fail.

Audit trail reports and the audit log management process

An audit trail report is a saved query over the audit table: everything one actor did, everything done to one record, or every sensitive action in a time window. A management process for a small team has 3 parts: a monthly review of admin and role events, an alert on unusual ones, and an export a customer can request.

Everything one actor did since a date:

select occurred_at, action, target_type, target_id, outcome
from audit_log
where actor_id = :actor and occurred_at >= :since
order by occurred_at;

The full history of one record:

select occurred_at, actor_type, actor_id, action, before, after
from audit_log
where target_type = :type and target_id = :id
order by occurred_at;

Every role, billing and deletion event in a window:

select * from audit_log
where action like any (array['role.%', 'member.%', 'plan.%', 'payment_method.%', '%.deleted'])
  and occurred_at between :from and :to
order by occurred_at;

For a team of two, the audit log management process I’d use is small (my working rule, rough guidance): a monthly review of about fifteen minutes over admin, role and billing events; one alert rule to start, such as a role change to admin or a bulk export outside working hours, routed like any other alert; and an export on request for a customer’s own workspace. When a revoked session or a forced sign-out is part of the story, that entry comes from the session layer, a separate topic: what active session management is. What a buyer’s security review asks about audit logs, and how to answer from these reports, is a separate subject: security questionnaire examples.

Audit logs you already have: GitHub, Slack, Zendesk audit logs, the Teams admin audit log, and their audit logs APIs

Platform audit logs record what happens inside that platform, not inside your app. GitHub, Slack, Zendesk and Microsoft 365 each keep a log of their own admin and access events, often limited by plan and, on higher plans, reachable through an audit logs API. What a user does inside your product is recorded only if your app writes it.

PlatformWhat it recordsPlan requiredAPIRetentionDocs checked on
GitHub organization audit logActions by members of the organization: who performed the action, what it was and whenNot stated in GitHub’s docs for viewing the log; only owners can access itGraphQL and REST API for organizations on GitHub Enterprise CloudThe last 180 days2026-09-27
SlackAudit events made of an actor, an action, an entity and a context; no message contentEnterprise planAudit Logs API, read onlyNot stated in Slack’s docs2026-09-27
ZendeskChanges agents and admins make to the account; end-user activity is not capturedEnterprise plans and aboveSupport APIKept indefinitely2026-09-27
Microsoft 365 (Purview audit, including Teams admin actions)User and admin operations across Microsoft services, including a Teams Admin Action record for Teams setting changesAudit (Standard) with the appropriate subscription; Audit (Premium) for longer retentionAudit Search Graph API180 days on Audit (Standard); up to 1 year on Audit (Premium); 10 years with an add-on license2026-09-27
StripeAn Event object when an API resource’s state changes, with information on the API request that triggered itNot stated in Stripe’s docsEvents APIRetrievable for 30 days2026-09-27

Sources: GitHub’s organization audit log docs, Slack’s Audit Logs API, Zendesk’s audit log guide, Microsoft Purview’s auditing overview and Stripe’s Events API, each read on 2026-09-27.

Changes a Teams admin makes, in the Teams admin center, in PowerShell or through the API, land in the Purview audit log as TeamsAdminAction records, and an admin finds them with the Audit log search tool in the Purview portal. In Zendesk, admins and agents with permission view the log in Admin Center or through the Support API.

What each of these leaves to your app is every action taken inside your product. The two fit together on billing: a dispute is answered by your plan.changed row plus Stripe’s event, when the row stores Stripe’s event id (my working rule), for as long as Stripe keeps events retrievable, which its docs put at 30 days. After that, the row itself has to hold what the dispute needs.

How to verify it

A sensitive-action audit log is verified with 6 checks: a role change, a deletion and a billing change each produce an accurate row with the right actor, an ordinary user is refused when requesting the log, the application’s database role is refused an UPDATE or DELETE on the table, and a denied action is logged as denied.

Run them in staging with two test users and one admin. The first test checks that the audit log records role changes with the right actor and the right values, and each test names the action, what a pass looks like and the evidence to keep.

  1. 01 As the admin, change the role of a test user. Pass: one row with the admin as actor, the user as target, the old and new role in before and after, a server-set time and the request id.
  2. 02 Delete a record. Pass: one row whose target id matches and whose before value identifies what was lost.
  3. 03 Change the plan twice: once in the app as the admin, once by a test-mode webhook for the subscription of that test user. Pass: two rows with different actor types.
  4. 04 Signed in as an ordinary user, call the audit log route and query the table through the client SDK with that session. Pass: a refusal both ways.
  5. 05 Connected as the app database role, confirm it is not the table owner, then run an UPDATE and a DELETE on the audit table. Pass: a permission error for both.
  6. 06 Signed in as an ordinary user, attempt a role change. Pass: a refusal, plus a row with outcome denied.

For test 3, change the subscription in the provider’s test-mode dashboard on the customer your app stored for that user. A bare stripe trigger is not this test: Stripe’s CLI docs say “all necessary API objects will be created in the process”, so in my reading the event it sends names no user of your app.

For test 4 on Supabase with the client roles’ grants revoked, Supabase’s guide says “A missing grant raises a 42501 error before any policy runs”; with the grants left in place and no policies, no rows come back. Either is a refusal; the exact response is whatever your own project returns.

For test 5, check the owner shown for the table in your database’s table listing before you trust the result. PostgreSQL’s docs cover the privileges but print no error text, so the evidence is the message your own database returns. A connection as the table’s owner fails this test even when the error appears, because an owner can re-grant itself the rights.

Then force a failure of the audited change, for example by rejecting it after the audit insert, and confirm no orphan row exists. Keep the six rows or refusals with their messages, the grants listing for the table (psql’s \dp prints it), and the date.

In the Production Hardening Sprint, deliverable 1.10 is verified this way: trigger the listed actions and verify accurate, access-controlled audit entries.

Where the sprint does this

Deliverable 1.10 logs role changes, deletions, and billing changes with the actor, action, and time, and protects access to the log ; how it is verified is the line at the end of the section above. The result is recorded in the production readiness report, deliverable 13.1, which accounts for all 123 IDs, keeps failures visible until resolved and explains genuine non-applicable items. Formal third-party certifications and independent audit opinions are separate from the sprint deliverables , and legal advice and certification are separate services: the sprint implements and documents the technical data-handling controls. The full list sits with the access control checks in the published scope.

Common questions about audit logs and audit trails

What are the two types of audit logs?

The split that matters to an app owner, in my working rule, is two kinds: application audit events, which your own code writes for actions such as role changes and deletions, and system or database-level audit logs, such as pgAudit’s statement logs and the logs GitHub, Slack or Microsoft keep for their own platforms. An app needs the first even when it has the second, because only the app knows which person was behind a request.

Is an audit trail mandatory?

It depends on the rules the business is under. In the United States, under the HIPAA Security Rule, a covered entity or business associate must, as the audit controls standard at 45 CFR 164.312(b), “Implement hardware, software, and/or procedural mechanisms that record and examine activity in information systems that contain or use electronic protected health information.” A customer contract may also require one. Where no rule or contract applies, an audit trail is good practice rather than a legal duty. This is not legal advice.

What are common audit trail mistakes?

Five stand out against the rules on this page: logging every request instead of a short list of sensitive actions, writing audit rows from the browser, writing the row outside the transaction that makes the change, leaving the table editable by the app’s own database role, and storing passwords, tokens or card data in the before and after values.

What is another name for an audit trail?

Audit log is the most common other name, and activity log, activity history and change history are used for the same thing. Event log is a broader term that also covers entries nobody audits. That is my reading of how the words are used, not a formal definition.