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.
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
SecureNow 8.1 adds `securenow alerts rules create` — define your own detection rules from SQL, scope them to your apps, and ship them without leaving the terminal. Here's how, with a real magic-link brute-force example.
Jun 11A copy-paste prompt that lets an AI coding agent install SecureNow, wire Next.js instrumentation, verify traces and logs, deploy to AWS, simulate attacks, and prove firewall blocking with human approval gates.
May 18
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