Skip to content

Audit Logs You Can Trust

Aman Kumar Singh8 min read
Part 32 of 40From the Full Stack SaaS Masterclass series
Audit Logs You Can Trust — article by Aman Kumar Singh

In Structured Logging for Node.js, I covered how to turn scattered console.log calls into structured, queryable events. That solves the "what happened in the system" problem. It does not solve a different, harder problem: proving what a specific user did to a specific piece of data, at a specific time, in a way that a customer's compliance team or your own support engineer can trust months later.

That is what an audit log is for. This is part of the Full Stack SaaS Masterclass series, and it sits at the point in a product's life where "we should probably log who deleted that" stops being a nice-to-have and starts being a support ticket, a security incident, or a line item in an enterprise contract.

Why audit logs are a different beast than application logs

Application logs answer "why did this request fail" or "how long did this query take." They're written for engineers, they're allowed to be noisy, and losing a percentage of them during an incident is an acceptable tradeoff for keeping the system up.

Audit logs answer "who did this, and can we prove it." The audience is different too: support staff investigating a complaint, a security team responding to an incident, an auditor checking SOC 2 evidence, or a customer's own admin looking at their organization's activity feed. Each reader needs a record that is complete, immutable, and attributable to a real actor.

This changes the requirements in three ways:

  • Completeness matters more than throughput. Dropping an audit entry under backpressure is worse than slowing down the request that triggered it.
  • Immutability matters. If the same code path that writes the log can also update or delete it, the log has no evidentiary value.
  • Actor identity matters more than a stack trace. You need "user 412 in organization 9, acting through API key X," not just "PATCH /invoices/88 returned 200."

A team building a SaaS product from scratch often reaches for audit logs too early, wiring them into every table with a generic trigger before they know which actions need auditing. I'd resist that. Start with the actions a customer, an auditor, or your support team would actually ask about, and expand as real questions come in.

What actually belongs in an audit entry

A useful audit record needs enough context to answer "who, what, when, where, and what changed" without a database migration to add a missing column later. A reasonable baseline schema:

create table audit_logs (
  id            bigserial primary key,
  occurred_at   timestamptz not null default now(),
  organization_id uuid not null references organizations(id),
  actor_id      uuid references users(id),
  actor_type    text not null default 'user', -- 'user' | 'api_key' | 'system'
  action        text not null,                -- e.g. 'invoice.updated'
  target_type   text not null,                -- e.g. 'invoice'
  target_id     text not null,
  ip_address    inet,
  user_agent    text,
  before_state  jsonb,
  after_state   jsonb,
  metadata      jsonb not null default '{}'::jsonb
);

create index idx_audit_logs_org_time on audit_logs (organization_id, occurred_at desc);
create index idx_audit_logs_target on audit_logs (organization_id, target_type, target_id);

A few of these choices are worth explaining rather than assuming.

action is a stable string, not a free-text description. Something like invoice.updated or member.role_changed lets you filter, alert, and build a customer-facing activity feed without parsing prose. Treat this naming with the same discipline you'd use for event names in an event-driven system: pick a convention early (resource.verb) and don't deviate per team.

Storing before_state and after_state as separate JSONB columns, rather than one combined diff, makes it trivial to render "changed status from draft to sent" without re-deriving anything from a patch document. There's a storage cost, though. For large records, log only the fields that changed, not the entire row, or a customer's big JSON blob will bloat this table fast.

organization_id on every row is non-negotiable in a multi-tenant system. Every query against this table should assume tenant scoping is the first filter, not an afterthought applied at the API layer.

Writing audit entries without polluting business logic

The naive approach is to call an auditLogService.log(...) at the end of every controller method that changes something. It works, but it's easy to forget, and it couples business logic to audit concerns in a way that makes both harder to test.

A cleaner pattern in NestJS is an interceptor that reads metadata off the route and emits an audit event after the handler succeeds, combined with a decorator so the intent is visible on the route itself:

// audit-log.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const AUDIT_ACTION_KEY = 'auditAction';

export interface AuditOptions {
  action: string;
  targetType: string;
}

export const Audit = (options: AuditOptions) =>
  SetMetadata(AUDIT_ACTION_KEY, options);
// audit-log.interceptor.ts
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { AUDIT_ACTION_KEY, AuditOptions } from './audit-log.decorator';
import { AuditLogService } from './audit-log.service';

@Injectable()
export class AuditLogInterceptor implements NestInterceptor {
  constructor(
    private readonly reflector: Reflector,
    private readonly auditLogService: AuditLogService,
  ) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const options = this.reflector.get<AuditOptions | undefined>(
      AUDIT_ACTION_KEY,
      context.getHandler(),
    );

    if (!options) {
      return next.handle();
    }

    const request = context.switchToHttp().getRequest();

    return next.handle().pipe(
      tap((result) => {
        void this.auditLogService.record({
          organizationId: request.user.organizationId,
          actorId: request.user.id,
          actorType: 'user',
          action: options.action,
          targetType: options.targetType,
          targetId: result?.id ?? request.params.id,
          ipAddress: request.ip,
          userAgent: request.headers['user-agent'],
          metadata: {},
        });
      }),
    );
  }
}
// invoices.controller.ts
import { Controller, Patch, Param, Body, UseInterceptors } from '@nestjs/common';
import { AuditLogInterceptor } from '../audit-log/audit-log.interceptor';
import { Audit } from '../audit-log/audit-log.decorator';

@Controller('invoices')
@UseInterceptors(AuditLogInterceptor)
export class InvoicesController {
  @Patch(':id')
  @Audit({ action: 'invoice.updated', targetType: 'invoice' })
  update(@Param('id') id: string, @Body() dto: UpdateInvoiceDto) {
    return this.invoicesService.update(id, dto);
  }
}

This keeps the service method free of audit-specific code and makes audited actions visible by scanning routes rather than reading every service. The interceptor only runs after the handler succeeds, which is deliberate: you generally want to audit what happened, not what was attempted and rejected. If you also need to record failed or denied attempts, useful for security-sensitive actions like permission changes, add a second interceptor or a guard-level hook rather than overloading this one.

One thing this pattern does not capture well is before_state. If you need the previous value for a diff, fetch it in the service before the mutation and pass it through request-scoped context, or have the service call the audit writer directly for that case. Don't force every audited action through the same mechanism if the data shape genuinely differs; an inconsistent implementation that captures the right data beats a uniform one that captures the wrong data.

Making the log tamper-evident, not just tamper-resistant

Restricting write access to the audit_logs table with database permissions is the first layer: no application role should have UPDATE or DELETE grants, only INSERT and SELECT. But permissions protect against accidental misuse, not against someone with superuser access or a compromised deployment credential.

For genuine tamper-evidence, the common technique is hash chaining. Each row stores a hash of its own content plus the hash of the previous row, so altering any historical entry breaks the chain and is detectable on verification.

import { createHash } from 'crypto';

function computeEntryHash(entry: {
  previousHash: string;
  occurredAt: string;
  action: string;
  targetId: string;
  actorId: string;
}): string {
  const payload = `${entry.previousHash}|${entry.occurredAt}|${entry.action}|${entry.targetId}|${entry.actorId}`;
  return createHash('sha256').update(payload).digest('hex');
}

Whether you need this depends entirely on your compliance target. Most early-stage SaaS products don't need cryptographic hash chains; restricted database grants, an append-only table, and shipping a copy of the logs to a write-once store (S3 with Object Lock, for instance) cover the realistic threat model. Reach for hash chaining when a specific compliance framework or a customer's security review demands proof of non-tampering, not by default. Building it earlier than that is complexity your team maintains for years without a corresponding benefit.

Retention, access, and the customer-facing activity feed

An audit log that nobody can query is just storage cost. Two consumers usually want access: your internal support and security teams, and the customer's own admins, who increasingly expect an "activity log" tab in the product itself.

For the internal case, keep the raw audit_logs table as the source of truth and build read paths on top of it rather than giving broad database access to support staff. For the customer-facing case, expose only a filtered subset: internal actions like background jobs, staff impersonation, or debugging generally should not appear in a customer's activity feed, and fields like raw IP addresses may need scrubbing.

Retention is a policy decision as much as a technical one. Regulated customers commonly expect years of retention; a typical SaaS product without that requirement can move audit rows older than a year or two into cheaper storage, keeping the hot table fast for recent activity and active investigations. Partitioning audit_logs by month, similar to a high-volume event table in PostgreSQL, makes this rollover mechanical instead of a manual job you have to remember to run.

Production pitfalls worth naming directly

A few mistakes show up repeatedly once teams start relying on audit logs for real incidents:

  • Writing the audit entry inside the same transaction as the business mutation, then rolling both back on an unrelated later error. A failure elsewhere in the transaction can silently erase evidence that something was attempted. Decide deliberately whether audit writes should be transactional or best-effort and asynchronous; both are valid, but pick one on purpose.
  • Logging the actor as a service account instead of the real user when actions go through a background job. If a scheduled job acts on a user's behalf, the entry should still record which user's data triggered it, ideally alongside the original request that queued the job.
  • No index on (organization_id, occurred_at). Without it, "show recent activity" becomes a sequential scan on a table that only grows, and it gets slower every month rather than staying flat.
  • Treating audit logs and structured application logs as the same system. They have different durability, access control, and retention needs. Mixing them into one log stream makes it hard to give a customer's admin visibility into their activity without exposing internal debug traces.

Key takeaways

  • Audit logs answer "who did this and can we prove it," a different requirement from application logging that deserves its own storage, schema, and access controls.
  • Design the schema around actor identity, a stable action name, and before/after state, not a free-text description that's hard to query later.
  • Use a decorator plus interceptor in NestJS to keep audit writes visible on the route and out of business logic, but don't force every action through the same mechanism if the data genuinely differs.
  • Restrict write access to append-only at the database level first; add cryptographic hash chaining only when a real compliance requirement asks for it.
  • Separate the customer-facing activity feed from the raw internal audit table, scrubbing internal-only actions before exposing it.
  • Plan retention and partitioning early; an unindexed, unpartitioned audit table is a quiet way a SaaS database slows down over time.

Frequently asked questions

What is the difference between an audit log and an application log?

An application log helps engineers debug behavior and can tolerate sampling or occasional loss. An audit log is a compliance and trust artifact recording who did what to which resource, and it needs completeness, immutability, and a real actor identity.

Should audit log writes be synchronous or asynchronous with the business action?

Either can work, but decide on purpose. Same-transaction writes guarantee the log and the action succeed or fail together, at the cost of coupling. Asynchronous writes protect the request path from audit-write latency, at the cost of needing their own retry or dead-letter handling.

Do I need blockchain or cryptographic hash chaining for audit logs?

Only if a specific compliance framework or customer security review requires proof of non-tampering beyond restricted database access. For most SaaS products, an append-only table with revoked update and delete permissions is a proportionate starting point.

How long should I retain audit logs?

It depends on customer compliance needs more than any general rule. Regulated industries often expect multi-year retention; otherwise, keep recent data in a fast, indexed table and move older rows to cheaper storage on a schedule.

Should the customer-facing activity feed show the same data as the internal audit table?

No. Show customers their own actions in a readable format, and exclude internal-only entries like staff impersonation. The raw table is your source of truth; the feed is a filtered view over it.

Where should audit logging live in a NestJS application?

As a dedicated module invoked through a decorator and interceptor, rather than scattered `service.log()` calls. This keeps audited actions visible on route definitions and testable in isolation from the business logic they observe.

Related articles

Role-Based Access Control (RBAC) in a SaaS — Aman Kumar Singh
A Production Security Checklist — Aman Kumar Singh
Multi-Tenancy Architecture for SaaS — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.