Capturing Request Bodies for Forensics in NestJS Without Leaking Secrets

NestJS interceptors give you a clean hook for body capture. Here's the redaction-aware pattern that works across REST, GraphQL, and microservices.

Lhoussine
May 9, 2026·5 min read

Capturing Request Bodies for Forensics in NestJS Without Leaking Secrets

NestJS Interceptors are the right hook for body capture. They have full ExecutionContext access, run after Guards, and can wrap handler invocation. Here's the pattern.

The redaction utility

// src/forensics/redact.ts
const SENSITIVE = new Set([
  'password', 'pass', 'pwd', 'token', 'access_token', 'refresh_token',
  'api_key', 'apikey', 'secret', 'authorization', 'cookie', 'session',
  'creditcard', 'card_number', 'cvv', 'cvc', 'ssn', 'tax_id',
]);

export function redact<T>(obj: T, depth = 0): T {
  if (depth > 10) return '[max-depth]' as any;
  if (obj == null || typeof obj !== 'object') return obj;
  if (Array.isArray(obj)) return obj.map(item => redact(item, depth + 1)) as any;

  const result: any = {};
  for (const [key, value] of Object.entries(obj)) {
    if (SENSITIVE.has(key.toLowerCase())) result[key] = '[REDACTED]';
    else if (typeof value === 'object') result[key] = redact(value, depth + 1);
    else result[key] = value;
  }
  return result;
}

The Interceptor

// src/forensics/forensics.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { trace } from '@opentelemetry/api';
import { redact } from './redact';

@Injectable()
export class ForensicsInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const req = context.switchToHttp().getRequest();
    const span = trace.getActiveSpan();

    if (span && req.body && Object.keys(req.body).length > 0) {
      span.setAttribute('http.request.body', JSON.stringify(redact(req.body)));
    }
    if (span && req.headers) {
      span.setAttribute('http.request.headers', JSON.stringify(redactHeaders(req.headers)));
    }

    return next.handle().pipe(
      tap((responseBody) => {
        if (span && responseBody) {
          span.setAttribute('http.response.body', JSON.stringify(redact(responseBody)));
        }
      })
    );
  }
}

function redactHeaders(headers: Record<string, any>) {
  const result: Record<string, string> = {};
  for (const [key, value] of Object.entries(headers)) {
    const lower = key.toLowerCase();
    result[key] = (lower === 'authorization' || lower === 'cookie' || lower.startsWith('x-api-key'))
      ? '[REDACTED]'
      : String(value);
  }
  return result;
}

Wiring globally

// app.module.ts
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ForensicsInterceptor } from './forensics/forensics.interceptor';

@Module({
  providers: [
    { provide: APP_INTERCEPTOR, useClass: ForensicsInterceptor },
  ],
})
export class AppModule {}

Now every controller route captures redacted bodies and headers automatically.

Per-controller / per-route

If global is too broad:

import { UseInterceptors } from '@nestjs/common';
import { ForensicsInterceptor } from './forensics/forensics.interceptor';

@Controller('users')
@UseInterceptors(ForensicsInterceptor)
export class UsersController {
  // ...
}

GraphQL

For GraphQL resolvers, the body is the variables object. The Interceptor pattern is identical, but you read from the GraphQL execution context:

import { GqlExecutionContext } from '@nestjs/graphql';

@Injectable()
export class GraphQLForensicsInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const ctx = GqlExecutionContext.create(context);
    const args = ctx.getArgs();
    const span = trace.getActiveSpan();
    span?.setAttribute('graphql.input', JSON.stringify(redact(args)));
    return next.handle();
  }
}

Microservices

For gRPC / RMQ / NATS handlers:

const data = context.switchToRpc().getData();
span?.setAttribute('rpc.payload', JSON.stringify(redact(data)));

Same redaction, different context type.

Size limits

Cap captured body size:

function safeStringify(obj: unknown, maxBytes = 8192): string {
  const str = JSON.stringify(redact(obj));
  return str.length > maxBytes ? str.slice(0, maxBytes) + '…[truncated]' : str;
}

The automatic option

node -r securenow/register dist/main.js

Auto-captures bodies, headers, and response payloads across REST, GraphQL, and microservice handlers — with redaction built in. The interceptor pattern above is what you'd reach for if you want fine-grained control or are not using SecureNow.

Related

Frequently Asked Questions

Should I use a Pipe, Interceptor, or Middleware?

Interceptor. Pipes transform values for handlers; Middleware runs before guards but has limited handler context; Interceptors run with full handler metadata, which is what you want for body capture.

Does this work for GraphQL?

Yes — NestJS Interceptors work for GraphQL resolvers via `@UseInterceptors()` on the resolver class. The body is the GraphQL variables object.

What about microservices (gRPC, NATS, RMQ)?

Same Interceptor pattern; the body is the message payload. Just capture in the appropriate `RpcArgumentsHost` instead of `HttpArgumentsHost`.

Is there an automatic option?

Yes — preload `securenow/register` and the SDK auto-captures bodies across all NestJS controllers, GraphQL resolvers, and microservice handlers, with redaction built in.

Recommended reading

Adding Backend Tracing to a Sentry Stack with OpenTelemetry

If your team uses Sentry for frontend errors and needs backend distributed tracing without doubling the Sentry bill, here's the OpenTelemetry path that doesn't make you choose.

May 9
How to Block Bot Traffic in Express With No Extra Infra

Five approaches to bot blocking in Express, ranked by effort vs. effectiveness. From a 5-line allowlist to a full IP-reputation firewall — all without Cloudflare, AWS WAF, or any new infrastructure.

May 9
How to Block Bot Traffic in Fastify With No Extra Infra

Fastify hooks (onRequest) and the SecureNow preload both work cleanly. Here's the production setup for IP blocking and user-agent filtering.

May 9