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
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 9Five 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 9Fastify hooks (onRequest) and the SecureNow preload both work cleanly. Here's the production setup for IP blocking and user-agent filtering.
May 9