Back to blog
audit-log·8 min read

How to Build an Audit Log in NestJS (Track Who Changed What, When)

A complete walkthrough of building an audit log in NestJS — design the audit_logs table, capture changes via a TypeORM subscriber, exclude sensitive fields, and resolve the actor automatically.

Khatab Wedaa

Khatab Wedaa

Software Engineer · Nestbolt

How to Build an Audit Log in NestJS (Track Who Changed What, When)

The first time someone asks "who changed this user's email?" or "when did this invoice get marked paid?", you find out whether you have an audit log. If you do, you answer in thirty seconds. If you don't, you spend a day cross-referencing application logs with database backups and still come up short. Auditability is one of those things that costs almost nothing to add up front and almost nothing in ongoing overhead, but is impossibly expensive to retrofit after a customer or a compliance auditor asks for it.

This guide walks through building a proper audit log in NestJS — the schema, the TypeORM subscriber that captures changes automatically, sensitive-field exclusion, and the actor resolution pattern that ties each change back to the user who made it. We'll use TypeORM, but the same shape works for Prisma or Sequelize.

What you'll build

By the end of this tutorial, your NestJS app will support:

  • An audit_logs table with action, entity_type, entity_id, old_values, new_values, and actor_id
  • Automatic capture of INSERT, UPDATE, and DELETE on any entity you opt in
  • A field-level except list so passwords, tokens, and PII never leak into the log
  • An actor resolver that pulls req.user.id from the current request without leaking it through every service call
  • A query API like auditLog.getAuditLogs("User", userId) for building a "history" tab in your admin UI

Prerequisites

  • Node.js 18 or later
  • A NestJS 10+ project with TypeORM configured
  • An authentication layer that puts the user on the request

1. Design the audit_logs table

The schema is small but every column matters:

// src/audit-log/entities/audit-log.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  Index,
} from "typeorm";
 
export type AuditAction = "created" | "updated" | "deleted";
 
@Entity("audit_logs")
@Index(["entityType", "entityId"])
@Index(["actorId"])
export class AuditLog {
  @PrimaryGeneratedColumn("uuid")
  id!: string;
 
  @Column({ type: "varchar", length: 32 })
  action!: AuditAction;
 
  @Column({ name: "entity_type", type: "varchar", length: 64 })
  entityType!: string;
 
  @Column({ name: "entity_id", type: "varchar", length: 64 })
  entityId!: string;
 
  @Column({ name: "old_values", type: "jsonb", nullable: true })
  oldValues!: Record<string, unknown> | null;
 
  @Column({ name: "new_values", type: "jsonb", nullable: true })
  newValues!: Record<string, unknown> | null;
 
  @Column({ name: "actor_id", type: "varchar", length: 64, nullable: true })
  actorId!: string | null;
 
  @CreateDateColumn({ name: "created_at" })
  createdAt!: Date;
}

A few decisions worth calling out:

  • entity_type is a string, not a foreign key. It refers to the entity class name, not a row. This is what makes the audit log work for every table without a schema change per entity.
  • old_values and new_values are JSON. For an updated row we store only the diff (changed fields). For created we store the full new state in new_values. For deleted we store the full old state in old_values.
  • Composite index on (entity_type, entity_id). This is the read pattern: "give me everything that ever happened to user abc-123".
  • actor_id is nullable. System jobs and migrations don't have an actor, and you want those changes logged too.

2. Capture changes with a subscriber

TypeORM has entity subscribers — class-based hooks that run on insert/update/remove. This is where we capture the diff:

// src/audit-log/audit-log.subscriber.ts
import {
  EntitySubscriberInterface,
  EventSubscriber,
  InsertEvent,
  UpdateEvent,
  RemoveEvent,
  DataSource,
} from "typeorm";
import { Inject } from "@nestjs/common";
import { AuditLogService } from "./audit-log.service";
import { AuditableMetadata } from "./auditable.metadata";
 
@EventSubscriber()
export class AuditLogSubscriber implements EntitySubscriberInterface {
  constructor(
    dataSource: DataSource,
    @Inject(AuditLogService) private readonly service: AuditLogService,
  ) {
    dataSource.subscribers.push(this);
  }
 
  async afterInsert(event: InsertEvent<unknown>) {
    const meta = AuditableMetadata.get(event.metadata.target);
    if (!meta) return;
    await this.service.log({
      action: "created",
      entityType: event.metadata.targetName,
      entityId: this.idOf(event.entity),
      newValues: this.scrub(event.entity, meta.except),
    });
  }
 
  async afterUpdate(event: UpdateEvent<unknown>) {
    const meta = AuditableMetadata.get(event.metadata.target);
    if (!meta) return;
 
    const changed = (event.updatedColumns ?? [])
      .map((c) => c.propertyName)
      .filter((p) => !meta.except.includes(p));
    if (changed.length === 0) return;
 
    const oldValues: Record<string, unknown> = {};
    const newValues: Record<string, unknown> = {};
    for (const key of changed) {
      oldValues[key] = (event.databaseEntity as Record<string, unknown>)[key];
      newValues[key] = (event.entity as Record<string, unknown>)[key];
    }
 
    await this.service.log({
      action: "updated",
      entityType: event.metadata.targetName,
      entityId: this.idOf(event.entity),
      oldValues,
      newValues,
    });
  }
 
  async afterRemove(event: RemoveEvent<unknown>) {
    const meta = AuditableMetadata.get(event.metadata.target);
    if (!meta) return;
    await this.service.log({
      action: "deleted",
      entityType: event.metadata.targetName,
      entityId: this.idOf(event.databaseEntity),
      oldValues: this.scrub(event.databaseEntity, meta.except),
    });
  }
 
  private scrub(entity: unknown, except: string[]) {
    const out: Record<string, unknown> = { ...(entity as object) };
    for (const key of except) delete out[key];
    return out;
  }
 
  private idOf(entity: unknown): string {
    return String((entity as { id: string }).id);
  }
}

Two important behaviors:

  • event.updatedColumns only includes fields TypeORM saw change. That's why the audit log row stores a diff, not a snapshot. Storing snapshots is fine for a small table, but on a 50-column entity it bloats your audit table fast.
  • The subscriber only fires for repository.save() and repository.remove(). Bulk operations like repository.update(id, data) and queryBuilder.update().execute() bypass it. This is a TypeORM constraint, not an oversight — for those paths you call auditLogService.log() manually.

3. Decorate the entities you want audited

Audit-logging every table is overkill. Most tables don't need it (cache rows, sessions, audit_logs itself). Use a decorator to opt in, with a per-entity exclusion list for sensitive fields:

// src/audit-log/auditable.decorator.ts
const META_KEY = Symbol("auditable");
 
export interface AuditableOptions {
  except?: string[];
}
 
export function Auditable(options: AuditableOptions = {}): ClassDecorator {
  return (target) => {
    Reflect.defineMetadata(
      META_KEY,
      { except: options.except ?? [] },
      target,
    );
  };
}
 
export const AuditableMetadata = {
  get: (target: unknown): { except: string[] } | undefined =>
    Reflect.getMetadata(META_KEY, target as object),
};

Now opt in on a User entity:

@Auditable({ except: ["passwordHash", "refreshTokenHash", "totpSecret"] })
@Entity("users")
export class User {
  @PrimaryGeneratedColumn("uuid")
  id!: string;
 
  @Column({ unique: true })
  email!: string;
 
  @Column({ name: "password_hash" })
  passwordHash!: string;
 
  @Column()
  name!: string;
}

Now any userRepository.save(user) produces an audit row with the new email and name, and passwordHash never appears anywhere in the log.

4. Resolve the actor automatically

The hardest part of an audit log is the question "who did this?" — and not breaking your service signatures to answer it. You don't want every update() method in the codebase to take an actorId parameter.

The clean answer is AsyncLocalStorage. Stash the actor on the request, then read it from the subscriber:

// src/audit-log/actor.context.ts
import { AsyncLocalStorage } from "node:async_hooks";
 
export const actorStorage = new AsyncLocalStorage<{ id: string }>();
// src/audit-log/actor.middleware.ts
@Injectable()
export class ActorMiddleware implements NestMiddleware {
  use(req: Request, _res: Response, next: NextFunction) {
    const user = (req as Request & { user?: { id: string } }).user;
    if (user) actorStorage.run({ id: user.id }, () => next());
    else next();
  }
}

Apply it after your auth middleware, then read the actor inside AuditLogService.log():

async log(entry: Omit<AuditLogEntry, "actorId" | "createdAt">) {
  const actor = actorStorage.getStore();
  await this.repo.save({ ...entry, actorId: actor?.id ?? null });
}

The service code stays clean — userRepository.save(user) works the same as it always did, but every call now produces an audit row with the right actor attached. Background jobs that don't run inside a request will produce rows with actorId: null, which is exactly what you want.

5. Query the history

Two read patterns cover 90% of audit-log queries: "everything for this entity" and "everything in this time window for this actor." Both are short:

// src/audit-log/audit-log.service.ts
async getAuditLogs(
  entityType: string,
  entityId: string,
  opts: { action?: AuditAction; from?: Date; limit?: number } = {},
) {
  const qb = this.repo
    .createQueryBuilder("log")
    .where("log.entity_type = :entityType", { entityType })
    .andWhere("log.entity_id = :entityId", { entityId })
    .orderBy("log.created_at", "DESC");
 
  if (opts.action) qb.andWhere("log.action = :action", { action: opts.action });
  if (opts.from) qb.andWhere("log.created_at >= :from", { from: opts.from });
  if (opts.limit) qb.limit(opts.limit);
 
  return qb.getMany();
}
 
getLatestAuditLog(entityType: string, entityId: string) {
  return this.repo.findOne({
    where: { entityType, entityId },
    order: { createdAt: "DESC" },
  });
}

A "history" tab in your admin UI is now one endpoint:

@Get(":id/history")
@UseGuards(JwtAuthGuard, AdminGuard)
history(@Param("id") id: string) {
  return this.auditLog.getAuditLogs("User", id, { limit: 100 });
}

6. Common pitfalls

PitfallWhat goes wrongFix
Storing full snapshots on every updateAudit table grows 10x faster than expectedStore diffs only — use event.updatedColumns
Using repo.update(id, data)Subscriber doesn't fire, change is invisibleUse repo.save(entity), or call auditLogService.log() manually
Logging passwords or tokensOne leaked DB dump exposes credentialsAlways pass except: ["passwordHash", ...] to @Auditable
Missing index on (entity_type, entity_id)History tab on a popular entity takes 30sAdd a composite index — see the entity definition above
Synchronous logging in a requestA slow audit insert blocks the user responseFor high-traffic apps, push log writes to a queue
Truncating entity_type to fitLong class names break inserts silentlyLength 64 is enough; never use varchar(16)
Audit-logging the audit table itselfInfinite loop on every insertThe subscriber early-returns when no @Auditable metadata is set

Going further: @nestbolt/audit-log

The code above works. The reason we extracted it into @nestbolt/audit-log is that the boilerplate adds up — the subscriber, the decorator, the metadata registry, the actor middleware, the service, the entity. That's six files of glue every project re-derives.

The package ships:

  • AuditLogModule.forRoot({ globalExcludedFields }) — register globally and pre-exclude fields like passwordHash across every audited entity
  • @Auditable({ except }) decorator with the same shape as step 3
  • AuditableMixin(BaseEntity) — extend it on your entity to get entity.getAuditLogs() and entity.getLatestAuditLog() methods directly
  • AuditLogService with the same getAuditLogs / getLatestAuditLog API from step 5
  • Automatic capture of repository.save() and repository.remove() (and the ActiveRecord variants)
  • Actor resolution via the same AsyncLocalStorage pattern from step 4

Setup is two lines — add AuditLogModule.forRoot() to your AppModule and @Auditable() to your entity. The full reference is in the audit-log quick-start.

Wrapping up

Audit logs feel like infrastructure work, the kind of thing you'll get to "next quarter." But the shape of an audit log is small — one table, one subscriber, one decorator — and once it's in, every entity you opt in is automatically traceable. Future-you, or future-your-compliance-team, will thank present-you.

If this saved you a few hours of writing the same boilerplate, star the repo. If you want to see a follow-up on actor resolution edge cases (background jobs, queue workers, server-to-server calls), open an issue on GitHub.

Khatab Wedaa

Written by Khatab Wedaa

Software Engineer · Nestbolt

Building open-source NestJS packages — authentication, permissions, audit logs, media uploads, and the patterns every backend ends up rebuilding.