Capture Request Bodies in NestJS Without Leaking Secrets

A redaction-aware NestJS interceptor that captures request bodies for forensics across REST, GraphQL and microservices without storing passwords or tokens.

May 9, 2026·5 min read

Capture Request Bodies in NestJS Without Leaking Secrets

With SecureNow 8.11.2, automatic capture also redacts code, otpCode, oneTimeCode, verificationCode, and passcode credentials in bodies, URL queries, and captured headers. Case/separator variants are covered; status_code and error_code remain useful in traces. Keep this rule in manual capture utilities too.

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 OTP_FIELDS = new Set(['code', 'otp', 'otpcode', 'onetimecode', 'onetimepassword', 'passcode', 'verificationcode', 'confirmationcode', 'emailcode', 'smscode', 'logincode', 'signincode', 'authcode', 'authenticationcode', 'mfacode', 'totpcode', '2facode', 'securitycode', 'recoverycode', 'backupcode']);
const isOtpField = (key) => OTP_FIELDS.has(key.replace(/^x[-_]/i, '').replace(/[^a-z0-9]/gi, '').toLowerCase());

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()) || isOtpField(key)) 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') || isOtpField(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 request bodies and headers across REST and GraphQL HTTP routes — with redaction built in (not response bodies or microservice message payloads). The interceptor pattern above is what you'd reach for if you want fine-grained control or are not using SecureNow.

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 HTTP request bodies across all NestJS controllers and GraphQL resolvers, with redaction built in. Microservice message payloads (gRPC, NATS, RMQ) are not captured.

Recommended reading

Block an IP, a Range or One Route Without a Deploy (Node.js)

Block one IP, a CIDR range, or a single route and method in a Node.js app within seconds, with no code change: setup, scoping, CLI, MCP, verification and undo.

Oct 27
11 Signs Your AI-Built App Needs a Security Audit

Built your app with a coding agent? 11 warning signs it needs a security audit, each checkable in five minutes, and how to run the full audit yourself.

Oct 26
AI Security Audit Part 1: Set Up Your Agent to Audit Code

Part 1 of the AI security audit series: set up an AI coding agent to audit your app locally, connect SecureNow, and run your first threat-model pass.

Jul 24