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
Software Engineer · Nestbolt
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_logstable withaction,entity_type,entity_id,old_values,new_values, andactor_id - Automatic capture of
INSERT,UPDATE, andDELETEon any entity you opt in - A field-level
exceptlist so passwords, tokens, and PII never leak into the log - An actor resolver that pulls
req.user.idfrom 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_typeis 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_valuesandnew_valuesare JSON. For anupdatedrow we store only the diff (changed fields). Forcreatedwe store the full new state innew_values. Fordeletedwe store the full old state inold_values.- Composite index on
(entity_type, entity_id). This is the read pattern: "give me everything that ever happened to userabc-123". actor_idis 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.updatedColumnsonly 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()andrepository.remove(). Bulk operations likerepository.update(id, data)andqueryBuilder.update().execute()bypass it. This is a TypeORM constraint, not an oversight — for those paths you callauditLogService.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
| Pitfall | What goes wrong | Fix |
|---|---|---|
| Storing full snapshots on every update | Audit table grows 10x faster than expected | Store diffs only — use event.updatedColumns |
Using repo.update(id, data) | Subscriber doesn't fire, change is invisible | Use repo.save(entity), or call auditLogService.log() manually |
| Logging passwords or tokens | One leaked DB dump exposes credentials | Always pass except: ["passwordHash", ...] to @Auditable |
Missing index on (entity_type, entity_id) | History tab on a popular entity takes 30s | Add a composite index — see the entity definition above |
| Synchronous logging in a request | A slow audit insert blocks the user response | For high-traffic apps, push log writes to a queue |
Truncating entity_type to fit | Long class names break inserts silently | Length 64 is enough; never use varchar(16) |
| Audit-logging the audit table itself | Infinite loop on every insert | The 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 likepasswordHashacross every audited entity@Auditable({ except })decorator with the same shape as step 3AuditableMixin(BaseEntity)— extend it on your entity to getentity.getAuditLogs()andentity.getLatestAuditLog()methods directlyAuditLogServicewith the samegetAuditLogs/getLatestAuditLogAPI from step 5- Automatic capture of
repository.save()andrepository.remove()(and the ActiveRecord variants) - Actor resolution via the same
AsyncLocalStoragepattern 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.
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.