Skip to content
← Back to blog
NestJSAudit LoggingComplianceSecurity

NestJS Audit Logging: Track Every User Action

Firas Sayah·March 18, 2026·5 min read

Why Audit Logging Matters

Enterprise customers require audit trails. When something goes wrong, they need to know who did what, when, and from where. Regulators like SOC 2, HIPAA, and GDPR require evidence of data access tracking. A well-designed audit logging system in NestJS serves both needs with minimal overhead.

Designing the Audit Log Schema

Store audit entries in a dedicated table optimized for querying:

@Entity()
@Index(['tenantId', 'createdAt'])
@Index(['userId', 'createdAt'])
export class AuditLog {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  tenantId: string;

  @Column()
  userId: string;

  @Column()
  action: string; // 'create', 'update', 'delete', 'read', 'login'

  @Column()
  resource: string; // 'user', 'invoice', 'project'

  @Column({ nullable: true })
  resourceId: string;

  @Column('jsonb', { nullable: true })
  changes: Record<string, { old: any; new: any }>;

  @Column()
  ipAddress: string;

  @Column({ nullable: true })
  userAgent: string;

  @CreateDateColumn()
  createdAt: Date;
}

The changes column stores a diff of what was modified, making it easy to see exactly what changed without querying historical snapshots.

Automatic Logging with an Interceptor

Create a global interceptor that logs every mutating API call:

@Injectable()
export class AuditInterceptor implements NestInterceptor {
  constructor(private auditService: AuditService) {}

  intercept(context: ExecutionContext, next: CallHandler) {
    const request = context.switchToHttp().getRequest();
    const method = request.method;

    if (['GET', 'HEAD', 'OPTIONS'].includes(method)) {
      return next.handle();
    }

    const startData = {
      userId: request.user?.id,
      tenantId: request.user?.tenantId,
      action: this.methodToAction(method),
      resource: this.extractResource(request.path),
      resourceId: request.params?.id,
      ipAddress: request.ip,
      userAgent: request.headers['user-agent'],
    };

    return next.handle().pipe(
      tap(() => this.auditService.log(startData)),
    );

<div class="not-prose my-8 rounded-xl border border-primary-200 dark:border-primary-800 bg-primary-50 dark:bg-primary-900/20 p-6">
<p class="text-base font-semibold text-primary-900 dark:text-primary-100 mb-1">Cloudrix SaaS Starter ships with this pre-configured and tested.</p>
<p class="text-sm text-primary-700 dark:text-primary-300 mb-3">Skip weeks of boilerplate — auth, payments, multi-tenancy, and deployment included out of the box.</p>
<a href="https://demo.cloudrix.io" class="inline-flex items-center text-sm font-medium text-primary-600 dark:text-primary-400 hover:text-primary-500">Try the live demo &rarr;</a>
</div>

  }

  private methodToAction(method: string): string {
    const map: Record<string, string> = {
      POST: 'create', PUT: 'update', PATCH: 'update', DELETE: 'delete',
    };
    return map[method] ?? 'unknown';
  }
}

Change Tracking with TypeORM Subscribers

For detailed field-level change tracking, use a TypeORM subscriber:

@EventSubscriber()
export class AuditSubscriber implements EntitySubscriberInterface {
  constructor(
    dataSource: DataSource,
    private auditService: AuditService,
    private clsService: ClsService,
  ) {
    dataSource.subscribers.push(this);
  }

  afterUpdate(event: UpdateEvent<any>) {
    const changes: Record<string, any> = {};
    for (const column of event.updatedColumns) {
      const field = column.propertyName;
      changes[field] = {
        old: event.databaseEntity?.[field],
        new: event.entity?.[field],
      };
    }

    if (Object.keys(changes).length > 0) {
      this.auditService.log({
        userId: this.clsService.get('userId'),
        tenantId: this.clsService.get('tenantId'),
        action: 'update',
        resource: event.metadata.tableName,
        resourceId: event.entity?.id,
        changes,
      });
    }
  }
}

The ClsService from nestjs-cls provides request-scoped context to the subscriber without passing it through every function call.

Custom Audit Decorator

For explicit control over what gets logged, create a decorator:

export const Audited = (resource: string, action?: string) =>
  SetMetadata('audit', { resource, action });

// Usage
@Audited('subscription', 'upgrade')
@Post('subscriptions/:id/upgrade')
async upgradePlan(@Param('id') id: string, @Body() dto: UpgradeDto) {
  return this.subscriptionService.upgrade(id, dto);
}

Querying the Audit Trail

Give admins the ability to search and filter audit logs:

@Controller('audit-logs')
@UseGuards(JwtAuthGuard, PermissionsGuard)
@RequirePermissions('audit:read')
export class AuditLogController {
  @Get()
  async search(@Query() query: AuditSearchDto) {
    const qb = this.auditRepo.createQueryBuilder('log')
      .where('log.tenantId = :tenantId', { tenantId: query.tenantId })
      .orderBy('log.createdAt', 'DESC');

    if (query.userId) qb.andWhere('log.userId = :userId', { userId: query.userId });
    if (query.resource) qb.andWhere('log.resource = :resource', { resource: query.resource });
    if (query.from) qb.andWhere('log.createdAt >= :from', { from: query.from });
    if (query.to) qb.andWhere('log.createdAt <= :to', { to: query.to });

    return qb.take(query.limit ?? 50).skip(query.offset ?? 0).getManyAndCount();
  }
}

Retention and Archival

Audit logs grow fast. Set up automatic archival to S3 and purge old records:

@Cron(CronExpression.EVERY_1ST_DAY_OF_MONTH_AT_MIDNIGHT)
async archiveOldLogs() {
  const cutoff = subMonths(new Date(), 12);
  const logs = await this.auditRepo.find({
    where: { createdAt: LessThan(cutoff) },
  });
  await this.s3.upload(logs, `audit/${format(cutoff, 'yyyy-MM')}.json.gz`);
  await this.auditRepo.delete({ createdAt: LessThan(cutoff) });
}

Keep at least 12 months online for quick access and archive indefinitely in cold storage for compliance.

Get Started Today

All of these patterns are fully implemented in SaaS Starter. Skip 2-6 weeks of setup.

F

Firas Sayah

Senior Software Engineer

Full-stack developer with 5+ years building production SaaS applications with NestJS and Angular.