Back to Blog

How to Design a SaaS Audit Log That Satisfies Enterprise Clients

Published: 2026-06-23
How to Design a SaaS Audit Log That Satisfies Enterprise Clients

We nearly lost an enterprise deal over a question nobody had prioritized answering: "Can you show us who changed what, and when?" The prospect's security review asked for a log of every admin action on their account for the past 90 days. Our honest answer was "somewhere in the application logs, give us a day to dig through them." That's a failing answer. The deal stalled until we built a proper queryable audit system.

The short answer: a SaaS audit log implementation for enterprise is a structured, immutable event store that captures who performed what action, on which resource, with before-and-after state, timestamped and attributable. This post is a complete SaaS audit log implementation enterprise guide — database schema, NestJS interceptor for automatic capture, SOC 2 compliance mapping, query UI for account admins, and the GDPR retention policies that make enterprise buyers comfortable.

Read our multi-tenant database architecture guide for context on how audit logs fit into the shared-schema tenant isolation model — every audit event should carry a tenant_id so queries scope to the right account.

Audit Logs ≠ Application Logs

The most common mistake is treating audit logging as "write a few extra lines to the application logger." Application logs and audit logs serve different masters and should be separate systems.

Application logs help engineers debug issues — errors, warnings, request traces, database query performance. They are high-volume (a production API may generate 10,000 entries per minute), ephemeral (retained 7–30 days), and often unstructured. You search them when something breaks and purge them when disk fills up.

Audit logs create a tamper-evident record of user actions for compliance, security, and customer transparency. They are structured (every entry has defined fields), immutable (you never update or delete entries), retained for years (12–36 months minimum), and must be queryable by non-engineers — account admins, compliance officers, and auditors who will never run a SQL query.

Conflating these two leads to logs that serve neither purpose: too structured and long-lived for debugging, too noisy and unstructured for compliance. Build them as separate storage with separate access patterns from day one.

The average data breach now costs $4.88 million (IBM Cost of a Data Breach 2024), and 24% of breaches start with stolen credentials (Verizon DBIR 2024). An audit log is the primary mechanism for investigating both — identifying which accounts were compromised and what data was accessed. If your "audit log" is grep on an application log file, you will not answer either question fast enough for the lawyers.

SaaS audit log implementation enterprise — security word tiles representing the compliance-focused nature of enterprise audit logging

What Enterprise Clients Actually Require

Enterprise buyers ask about audit logs in their security questionnaire before they ask about pricing. The requirements are specific and non-negotiable:

Immutable record. Logged events must never be editable or deletable through the application. If a compromised admin account can cover its tracks by deleting log entries, the audit log has no value. The only deletion mechanism should be a scheduled retention policy — and even that should be logged as an event.

Attributable to an actor. Every event must tie back to a specific identity: user ID, API token name, or system process. Anonymous events (like a failed login with no matching user) should be explicitly marked as such rather than attributed to "system."

Time-synced timestamps. UTC with millisecond precision, from an NTP-synced server. Enterprise security teams routinely merge your audit log with their own SIEM data, and timezone-naive or low-precision timestamps make that integration unreliable.

Exportable. CSV or JSON export, ideally both. Enterprise security teams export audit data for inclusion in their own compliance reporting. If they can't export it, they create a support ticket, and every support ticket asking for data they should be able to pull themselves is a friction point that shortens your evaluation window.

Searchable and filterable. By actor, event type, date range, and affected resource. Account admins need to answer questions like "who changed the billing plan last week?" without calling support. A filter UI covering these four dimensions covers 90% of operational queries.

The SaaS Audit Log Implementation Data Model

An audit log entry is useful when it stands alone — you should understand what happened without referencing the rest of your database. Every event needs enough context to be self-describing.

TypeScript
1interface AuditLogEntry {
2  id: string;
3  tenantId: string;
4  timestamp: string;       // ISO 8601 UTC
5  
6  // Who
7  actorId: string;
8  actorType: 'user' | 'admin' | 'api_key' | 'system';
9  actorEmail?: string;
10  actorName?: string;
11  
12  // What
13  action: string;          // e.g. 'user.role.updated'
14  actionCategory: 'create' | 'read' | 'update' | 'delete';
15  
16  // On what
17  entityType: string;      // e.g. 'user', 'invoice', 'api_key'
18  entityId: string;
19  
20  // Context
21  ipAddress: string;
22  userAgent: string;
23  source: 'ui' | 'api' | 'admin' | 'automation';
24  correlationId?: string;
25  
26  // State changes
27  oldValues?: Record<string, unknown>;
28  newValues?: Record<string, unknown>;
29  metadata?: Record<string, unknown>;
30}

The key design decisions:

  • Action naming convention uses resource.subresource.action dot notation — user.created, team.member.removed, subscription.plan_changed. This makes filtering and aggregation straightforward.
  • oldValues and newValues store diffs, not snapshots. For an UPDATE, store only the changed fields. For CREATE, store full state in newValues. For DELETE, store full prior state in oldValues. Snapshotting the whole entity on every update bloats the audit table far faster than diff-based logging — in our experience the diff adds roughly a millisecond per write, while full snapshots multiply your storage several times over for data you rarely read back.
  • tenantId at the event level means every query is automatically scoped. When an account admin views their audit log, the WHERE clause is tenant_id = ?. Without this field, you need join-based scoping that gets expensive at scale.
  • actorType distinguishes customer users from internal team. This matters for SOC 2: your compliance team needs to audit what internal staff did (who accessed a customer account, who applied a credit) separately from what the customer's own users did.

A complex network of cables in a data center with server equipment, representing the infrastructure behind audit log storage

Building the NestJS Audit Interceptor

The reliable way to capture audit events in NestJS is a two-layer approach: an interceptor for request-level context and a subscriber for entity-level changes. The interceptor captures the "who, when, where" from the HTTP request, and the subscriber captures the "what changed" from the database operation.

Layer 1: Actor context via AsyncLocalStorage

The hardest part of audit logging is tying every database change back to the user who triggered it without threading an actorId parameter through every service method. The clean solution is Node.js AsyncLocalStorage:

TypeScript
1// src/audit-log/actor.context.ts
2import { AsyncLocalStorage } from 'node:async_hooks';
3
4export interface ActorContext {
5  id: string;
6  type: 'user' | 'admin' | 'api_key' | 'system';
7  email?: string;
8  name?: string;
9  ipAddress: string;
10  userAgent: string;
11  correlationId?: string;
12}
13
14export const actorStorage = new AsyncLocalStorage<ActorContext>();

Apply a NestJS middleware after your auth layer to populate it:

TypeScript
1// src/audit-log/actor.middleware.ts
2import { Injectable } from '@nestjs/common';
3@Injectable()
4export class ActorMiddleware implements NestMiddleware {
5  use(req: Request, _res: Response, next: NextFunction) {
6    const user = (req as any).user;
7    if (user) {
8      actorStorage.run({
9        id: user.id,
10        type: user.role === 'admin' ? 'admin' : 'user',
11        email: user.email,
12        ipAddress: req.ip ?? '',
13        userAgent: req.headers['user-agent'] ?? '',
14        correlationId: req.headers['x-correlation-id'] as string,
15      }, () => next());
16    } else {
17      next();
18    }
19  }
20}

Layer 2: TypeORM subscriber for automatic change capture

A subscriber hooks into entity lifecycle events — afterInsert, afterUpdate, afterRemove — and writes audit rows automatically:

TypeScript
1// src/audit-log/audit-log.subscriber.ts
2import { EventSubscriber } from 'typeorm';
3@EventSubscriber()
4export class AuditLogSubscriber implements EntitySubscriberInterface {
5  constructor(
6    dataSource: DataSource,
7    private readonly auditLogService: AuditLogService,
8  ) {
9    dataSource.subscribers.push(this);
10  }
11
12  async afterInsert(event: InsertEvent<unknown>) {
13    const meta = this.getAuditableMeta(event.metadata.target);
14    if (!meta) return;
15    await this.auditLogService.log({
16      action: `${event.metadata.targetName.toLowerCase()}.created`,
17      entityType: event.metadata.targetName,
18      entityId: String((event.entity as any).id),
19      actionCategory: 'create',
20      newValues: this.scrub(event.entity, meta.except),
21      source: actorStorage.getStore()?.type === 'admin' ? 'admin' : 'ui',
22    });
23  }
24
25  async afterUpdate(event: UpdateEvent<unknown>) {
26    const meta = this.getAuditableMeta(event.metadata.target);
27    if (!meta) return;
28
29    const changedColumns = (event.updatedColumns ?? [])
30      .map((c) => c.propertyName)
31      .filter((p) => !meta.except.includes(p));
32    if (changedColumns.length === 0) return;
33
34    const oldValues: Record<string, unknown> = {};
35    const newValues: Record<string, unknown> = {};
36    for (const key of changedColumns) {
37      oldValues[key] = (event.databaseEntity as any)[key];
38      newValues[key] = (event.entity as any)[key];
39    }
40
41    await this.auditLogService.log({
42      action: `${event.metadata.targetName.toLowerCase()}.updated`,
43      entityType: event.metadata.targetName,
44      entityId: String((event.entity as any).id),
45      actionCategory: 'update',
46      oldValues,
47      newValues,
48      source: actorStorage.getStore()?.type === 'admin' ? 'admin' : 'ui',
49    });
50  }
51
52  private scrub(entity: unknown, except: string[]): Record<string, unknown> {
53    const out = { ...(entity as object) };
54    for (const key of except) delete (out as any)[key];
55    return out;
56  }
57
58  private getAuditableMeta(target: unknown) {
59    return Reflect.getMetadata('auditable', target as object) as
60      { except: string[] } | undefined;
61  }
62}

Use a decorator to opt entities into auditing and exclude sensitive fields:

TypeScript
1@Auditable({ except: ['passwordHash', 'refreshTokenHash', 'totpSecret'] })
2@Entity('users')
3export class User {
4  @PrimaryGeneratedColumn('uuid')
5  id!: string;
6  @Column() email!: string;
7  @Column({ name: 'password_hash' }) passwordHash!: string;
8  @Column() name!: string;
9}

This means userRepository.save(user) automatically produces an audit row, and passwordHash never appears anywhere in the log. No manual log() calls needed in service methods.

Layer 3: The audit log service

TypeScript
1// src/audit-log/audit-log.service.ts
2import { Injectable } from '@nestjs/common';
3import { InjectRepository } from '@nestjs/typeorm';
4@Injectable()
5export class AuditLogService {
6  constructor(
7    @InjectRepository(AuditLog) private readonly repo: Repository<AuditLog>,
8  ) {}
9
10  async log(entry: Omit<AuditLogEntry, 'id' | 'timestamp' | 'actorId' |
11    'actorType' | 'actorEmail' | 'actorName' | 'ipAddress' | 'userAgent' |
12    'correlationId' | 'tenantId'>) {
13    
14    const actor = actorStorage.getStore();
15    const request = this.getCurrentRequest();
16    const tenantId = request?.user?.tenantId ?? 'system';
17
18    await this.repo.save({
19      ...entry,
20      tenantId,
21      actorId: actor?.id ?? null,
22      actorType: actor?.type ?? 'system',
23      actorEmail: actor?.email ?? null,
24      actorName: actor?.name ?? null,
25      ipAddress: actor?.ipAddress ?? '',
26      userAgent: actor?.userAgent ?? '',
27      correlationId: actor?.correlationId ?? null,
28    });
29  }
30}

A laptop displaying 'Cyber Security' concepts in a modern office setting

SOC 2 and Compliance Requirements

SOC 2 has a direct dependency on audit logging. The Trust Services Criteria require evidence across three areas where audit logs are the primary or supporting control:

Logical access (CC6.1, CC6.2). Audit logs of user creation, permission changes, and role assignments provide evidence that access is being tracked and managed. Your auditor will ask: who has access to what? Show them the audit trail of every access change.

Change management (CC8.1). Audit logs of configuration changes, data modifications, and schema changes provide evidence that changes are tracked and attributable. Show the auditor: here is every change to production data, tagged with the identity that made it.

Monitoring (CC7.2, CC7.3). Audit logs combined with alerting — alerts for unusual volume of admin actions, impersonation use, bulk data exports — provide evidence of active monitoring.

Companies that add audit logging retroactively for a SOC 2 audit spend significantly more time and money than those who built it in from the start. Retroactive implementation requires backfilling events (where possible), documenting gaps, and explaining to the auditor why logging only covers a subset of the relevant period. Building it proactively means your first SOC 2 audit reviews a complete, structured audit record — which auditors notice and which shortens the process considerably.

Building the Query Surface for Your Ops and Customer Teams

Event capture is the easy half. The query surface is where most implementations fall short — not because it's technically hard, but because it gets deprioritized. Engineers implement the capture and move on. Six months later, every audit query goes to engineering because there is no non-engineer-accessible interface.

The queries your team runs regularly, without database access:

Account activity timeline. All events for a specific account in a date range, sorted by time. This answers customer escalations: "what changed in our account on Tuesday?"

Actor history. All actions by a specific user or internal team member. This answers: "what did support rep Sarah do to account 4512 last week?" — the compliance team's most common question.

Event type filter. All events of a specific type. "How many billing plan changes happened in March?"

Affected entity search. All events for a specific resource — a particular API key, a user record, an invoice.

For enterprise customers, the audit log viewer should be embedded in the product and filterable by actor, event type, date range, and source. The export button should generate a CSV the customer's security team can import into their own SIEM.

One endpoint to support all of this:

TypeScript
1@Get('audit-logs')
2@UseGuards(JwtAuthGuard)
3async query(
4  @Query('tenantId') tenantId: string,
5  @Query('actorId') actorId?: string,
6  @Query('eventType') eventType?: string,
7  @Query('from') from?: string,
8  @Query('to') to?: string,
9  @Query('page') page = 1,
10  @Query('limit') limit = 50,
11) {
12  return this.auditLogService.query({
13    tenantId, actorId, eventType,
14    from: from ? new Date(from) : undefined,
15    to: to ? new Date(to) : undefined,
16    page, limit,
17  });
18}

Retention Policies, GDPR, and Export

Audit log data is append-only and grows indefinitely. Without a retention policy, your storage costs increase linearly with time and query performance degrades as the table grows.

Minimum retention: 12 months for most SaaS companies. Recommended: 24–36 months for SOC 2 or regulated industries (healthcare, finance, government).

GDPR considerations. Audit logs contain personal data — actor email, IP address, user agent. Under GDPR, users have the right to erasure ("right to be forgotten"), but audit logs have a specific exemption: controller overriding legitimate interest. Your auditor will expect you to document why audit logs are retained despite containing personal data. The standard argument is that audit logs serve a legitimate interest in security and legal compliance. Document it, and anonymize the actor reference rather than deleting the log row entirely — replace actorEmail with a hash and ipAddress with a truncation.

Export for compliance. Enterprise customers need to export audit data for their own compliance reporting. CSV for easy spreadsheet analysis, JSON for SIEM integration. The export should respect the same filters as the query UI:

TypeScript
1@Get('audit-logs/export')
2@UseGuards(JwtAuthGuard, EnterpriseGuard)
3async export(@Query() filters: AuditLogQueryDto) {
4  const rows = await this.auditLogService.queryAll(filters);
5  const csv = this.csvService.serialize(rows);
6  return new StreamableFile(csv, {
7    type: 'text/csv',
8    disposition: `attachment; filename="audit-log-${Date.now()}.csv"`,
9  });
10}

Retention enforcement. Never allow manual deletion through the application. The only deletion mechanism should be a scheduled job that runs monthly (or quarterly, depending on your retention window) and removes entries older than your policy:

TypeScript
1@Cron('0 3 1 * *') // First of every month at 3am
2async purgeExpiredLogs() {
3  const cutoff = new Date();
4  cutoff.setFullYear(cutoff.getFullYear() - 3); // 36-month retention
5  await this.auditLogService.deleteOlderThan(cutoff);
6}

Indexing and Performance

The query patterns for audit logs are predictable, which makes indexing straightforward. Two composite indexes cover 95% of queries:

SQL
1CREATE INDEX idx_audit_logs_tenant_time
2  ON audit_logs (tenant_id, created_at DESC);
3
4CREATE INDEX idx_audit_logs_entity
5  ON audit_logs (entity_type, entity_id, created_at DESC);
6
7CREATE INDEX idx_audit_logs_actor
8  ON audit_logs (actor_id, created_at DESC);

The (tenant_id, created_at) index supports the primary query pattern: "show me everything for this account in reverse chronological order." The (entity_type, entity_id) index supports "show me everything that happened to this specific resource." The (actor_id) index supports "show me everything this user did."

Audit logging adds measurable but acceptable overhead. In our own benchmarking on PostgreSQL, a CREATE with audit logging runs on the order of a millisecond more than the same write without it, and an UPDATE that computes a field-level diff adds a little more on top. For most SaaS products with thousands of writes per day, that overhead is invisible. For high-throughput products, push audit writes to a queue (BullMQ — see our background job queue guide) and batch-insert them every few seconds so the audit write never sits in the user's request path.

Teams working on multi-tenant SaaS architectures often find that audit logs interact with RLS in interesting ways — see our PostgreSQL RLS guide for the tenant-context patterns that apply here too.

Wooden letter tiles spelling the word 'COMPLIANCE' on a rustic wooden background

The Audit Log That Closed the Deal

The audit log we built after that stalled enterprise deal tracks every state-changing operation in the application — user management, billing changes, API key operations, data exports, and admin panel access. It is queryable by account admins through a filterable timeline UI, exportable to CSV with one click, and retained for 36 months with automated purging. The same feature that unblocked that deal became a checklist item in every subsequent enterprise security review. We start every SaaS audit log implementation the same way: the schema, the interceptor, the subscriber, and the query surface. Everything else — event types, retention windows, export formats — is configuration on top of that foundation.

If you're in an enterprise evaluation right now and wondering whether your audit log covers enough, here is the test: ask your team to show you every action that happened on a specific account in the last 90 days, sorted by time, with before-and-after values. If that query takes longer than a minute or requires a database connection, you have the same gap we had — and it's cheaper to fix before the prospect asks.

And if you're building this right now and want to talk through the schema design or the interceptor pattern, get in touch. Audit logs are one of those things where the first draft determines whether it's a feature or a compliance burden — and we've written enough first drafts to know the difference.

Frequently Asked Questions

A SaaS audit log is an immutable, queryable record of who did what, when, and to which data within your application. Enterprise clients need audit logs for security monitoring, compliance verification (SOC 2, GDPR), incident investigation, and account transparency — being able to see every action taken on their account by both their users and your support team.

Always capture: user creation and deletion, permission and role changes, login attempts (success and failure), billing and subscription changes, data exports and bulk operations, API key creation and revocation, and admin panel access to customer accounts. Optionally capture: read operations on sensitive data (PII, financial records) and configuration changes. Don't log: every pageview, health checks, or internal system calls.

Build it in three layers: a database table (audit_logs) with columns for actor_id, action, entity_type, entity_id, old_values, new_values, ip_address, and user_agent; an interceptor that captures controller method invocations and logs the request context; and a subscriber (TypeORM EntitySubscriber) that automatically captures entity create/update/delete events with before-and-after state diffs. Use a decorator to opt entities into auditing with an exclusion list for sensitive fields like password hashes.

Application logs help engineers debug issues — errors, warnings, request traces — and are high-volume, ephemeral, and often unstructured. Audit logs create an immutable record of user actions for compliance and security, are structured, retained for years, and must be queryable by non-engineers. They should be separate systems with different storage, retention, and access policies.

12 months is the minimum for most SaaS companies. 24–36 months is appropriate if you're pursuing SOC 2 Type II or serving regulated industries like healthcare or finance. The retention policy should be documented, enforced programmatically, and visible to your compliance team. Never allow manual deletion of audit log entries through the application — the only deletion mechanism should be an automated retention policy.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio