How I build audit logging in NestJS with interceptors, JWT-derived actor attribution, before-and-after entity snapshots, append-only tables, PII masking, and retention rules that hold up to a compliance review.
A creator on the branded-mobile-app product I’d shipped for the creator economy platform I worked at opened a ticket: “all my customers were charged twice this month and they each show two active subscriptions.” We pulled logs. Apple’s renewal notification had been retried after our endpoint returned a 200 OK slightly past their 30 second deadline. Every retry had created a new subscription row. The reason we could untangle it at all was the audit table sitting next to the subscriptions table. Without it I’d have been guessing.
That incident is the reason I treat audit logging in NestJS as a first-class concern, not a “we’ll add it before SOC 2.” Here’s how I’d build it today.
An audit log is not a logger. Logs are for engineers. Audit records are for auditors, lawyers, customers, and your future self at 2 a.m. They need to answer four questions, every time: who did it, what did they do, what was the state before, what is the state after. That’s it. If your audit row can’t answer those, it’s just structured logging with extra steps.
Two design rules fall out of that. The table is append-only, full stop. No updates, no soft deletes, no “fix the bad row.” If a row is wrong you write another row explaining why. And the actor has to be attributed from a trusted boundary, not from whatever the handler felt like passing in. In NestJS that boundary is the auth guard.
I prefer an interceptor over a decorator-only approach. Decorators are great for marking intent on a controller, but the interceptor is where the request, the response, the user, and the timing all live in one place. Mark the controller, let the interceptor do the work.
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable, tap } from 'rxjs';
import { AUDIT_META, AuditMeta } from './audit.decorator';
import { AuditWriter } from './audit.writer';
import { maskPii } from './pii';
@Injectable()
export class AuditInterceptor implements NestInterceptor {
constructor(
private readonly reflector: Reflector,
private readonly writer: AuditWriter,
) {}
intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
const meta = this.reflector.get<AuditMeta>(AUDIT_META, ctx.getHandler());
if (!meta) return next.handle();
const req = ctx.switchToHttp().getRequest();
const actor = req.user;
if (!actor?.sub) {
// No verified actor, no audit. Guard should have rejected already.
return next.handle();
}
const startedAt = Date.now();
const before = req.body?.__auditBefore ?? null;
return next.handle().pipe(
tap({
next: (after) => {
this.writer.append({
actorId: actor.sub,
actorRole: actor.role,
tenantId: actor.tenantId,
action: meta.action,
entity: meta.entity,
entityId: req.params?.id ?? after?.id ?? null,
before: maskPii(before, meta.maskPaths),
after: maskPii(after, meta.maskPaths),
requestId: req.id,
ip: req.ip,
userAgent: req.headers['user-agent'],
latencyMs: Date.now() - startedAt,
status: 'success',
});
},
error: (err) => {
this.writer.append({
actorId: actor.sub,
actorRole: actor.role,
tenantId: actor.tenantId,
action: meta.action,
entity: meta.entity,
entityId: req.params?.id ?? null,
before: maskPii(before, meta.maskPaths),
after: null,
requestId: req.id,
ip: req.ip,
userAgent: req.headers['user-agent'],
latencyMs: Date.now() - startedAt,
status: 'error',
errorCode: err?.code ?? 'unknown',
});
},
}),
);
}
}
The decorator is the boring half of the pair. It just declares intent.
import { SetMetadata } from '@nestjs/common';
export const AUDIT_META = 'audit:meta';
export interface AuditMeta {
action: string;
entity: string;
maskPaths?: string[];
}
export const Audit = (meta: AuditMeta) => SetMetadata(AUDIT_META, meta);
Apply it on the controller method:
@Patch(':id')
@Audit({
action: 'subscription.update',
entity: 'subscription',
maskPaths: ['paymentMethod.token', 'email'],
})
async update(
@Param('id') id: string,
@Body() dto: UpdateSubscriptionDto,
@CurrentUser() user: AuthUser,
) {
const before = await this.subs.findById(id);
(this.request as any).body.__auditBefore = before;
return this.subs.update(id, dto, user);
}
The before snapshot has to be captured inside the handler. The interceptor sees the request after the guard, but it does not know what your service is about to mutate. Hand it the snapshot explicitly.
Trust the guard. Nothing else. The guard’s job is to verify the token, pull the claims, and attach a typed AuthUser to the request. The interceptor reads from there.
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
export interface AuthUser {
sub: string;
role: 'admin' | 'editor' | 'viewer';
tenantId: string;
scopes: string[];
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwt: JwtService) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const req = ctx.switchToHttp().getRequest();
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) return false;
const token = header.slice(7);
const claims = await this.jwt.verifyAsync<AuthUser>(token);
if (!claims?.sub || !claims?.tenantId) return false;
req.user = claims;
return true;
}
}
I’ve seen audit tables in the wild that store actor_id from a custom header. That is not audit logging, that is “the client told us who it was.” Anyone with a curl can rewrite history. The audit row’s actor must come from a verified token claim or it isn’t trustworthy.
The table itself is dull and that’s the point.
import { Entity, PrimaryGeneratedColumn, Column, Index } from 'typeorm';
@Entity({ name: 'audit_events' })
@Index(['tenantId', 'entity', 'entityId', 'createdAt'])
@Index(['actorId', 'createdAt'])
export class AuditEvent {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column() tenantId: string;
@Column() actorId: string;
@Column() actorRole: string;
@Column() action: string;
@Column() entity: string;
@Column({ nullable: true }) entityId: string | null;
@Column({ type: 'jsonb', nullable: true }) before: unknown;
@Column({ type: 'jsonb', nullable: true }) after: unknown;
@Column() requestId: string;
@Column({ nullable: true }) ip: string;
@Column({ nullable: true }) userAgent: string;
@Column() latencyMs: number;
@Column() status: 'success' | 'error';
@Column({ nullable: true }) errorCode: string | null;
@Column({ type: 'timestamptz', default: () => 'now()' }) createdAt: Date;
}
Two things here are not negotiable. There’s no updatedAt. No deletedAt. Postgres-level revokes on the role your app uses, so even your own code cannot UPDATE this table. Set it up once and forget.
The other thing is partitioning. Audit tables grow fast and are append-only by nature. Partition by month, set a retention job per partition, and any maintenance command goes through a wrapper that refuses to run during business hours.
Don’t rely on a code review catching that someone audited a password field. Mask at the boundary, on a path list declared next to the action.
export function maskPii<T>(input: T, paths: string[] = []): T {
if (!input || typeof input !== 'object') return input;
const clone = structuredClone(input);
for (const path of paths) {
const segments = path.split('.');
let cursor: any = clone;
for (let i = 0; i < segments.length - 1; i++) {
cursor = cursor?.[segments[i]];
if (!cursor) break;
}
const last = segments[segments.length - 1];
if (cursor && last in cursor) cursor[last] = '***';
}
return clone;
}
It’s three lines of logic and it has saved me twice. Both times in code review, when someone shipped a new endpoint and forgot to declare maskPaths. The decorator forces the conversation.
The duplicate billing story I opened with is the real reason the audit pipeline exists in the shape it does. Apple’s SubscriptionRenewal notification got retried after our endpoint slipped past 30 seconds. The renewal handler had no idempotency check, so every retry inserted a new row in creator_subscriptions. A few thousand customers across dozens of branded apps were affected by the time we noticed.
The first fix that went out was a frontend patch that showed only the latest subscription per customer. That hid the rows. It didn’t fix the charge. Apple wasn’t going to refund anything because we’d updated our UI. Customer escalated to legal.
The real fix had two halves. First, a unique constraint on (apple_original_transaction_id, notification_uuid) made retries idempotent at the database. Second, the audit table was the source of truth for which notification_uuid values we had already accepted and what state they had moved subscriptions through. The cleanup script joined audit rows against the subscriptions table to dedupe rows by retaining the earliest insertion. Refunds took about four days through Apple’s developer support API because their API requires per-transaction approval at our volume.
What stuck with me. Server-to-server notifications from Apple and Google retry aggressively. Any of them can be received twice. Idempotency keys aren’t optional, and the audit table is what lets you reconstruct what actually happened when the constraint fires for the first time in production. SOC 2 and GDPR retention rules then sit on top. We kept audit rows seven years for financial actions, ninety days for read-only events. The partition strategy made retention a DROP PARTITION, not a DELETE.
UPDATE grant, partition by month, retention as DROP PARTITION.before snapshot has to be loaded by the handler. The interceptor can’t guess it.Thanks for reading. If you’ve got thoughts, send them my way.