Capture Request Bodies in Next.js Without Leaking Secrets

Redaction-aware request body capture for Next.js Route Handlers and Server Actions in the App Router, so traces show what was sent without leaking secrets.

May 9, 2026·6 min read

Capture Request Bodies in Next.js 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.

For incident investigation in Next.js, request bodies in your traces are the difference between "something failed" and "we know exactly what they sent." The bad version logs everything including passwords; the good version redacts before storing.

Here's the App Router pattern. For broader context see the application security monitoring guide.

The redaction utility

Same pattern as Express, packaged for reuse:

// lib/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 || obj === undefined) return obj;
  if (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;
}

Route Handlers (App Router)

// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { trace } from '@opentelemetry/api';
import { redact } from '@/lib/redact';

export async function POST(req: NextRequest) {
  const body = await req.json();

  const span = trace.getActiveSpan();
  span?.setAttribute('http.request.body', JSON.stringify(redact(body)));

  // ... your handler logic
  const user = await createUser(body);
  return NextResponse.json({ user });
}

The trace.getActiveSpan() requires OpenTelemetry instrumentation set up via instrumentation.ts. If you're not yet on OTel, you can instead log the redacted body directly:

console.log(JSON.stringify({
  type: 'request',
  path: '/api/users',
  body: redact(body),
}));

Server Actions

Server Actions in App Router get the body as the function arguments:

// app/actions/create-order.ts
'use server';

import { trace } from '@opentelemetry/api';
import { redact } from '@/lib/redact';

export async function createOrder(formData: FormData) {
  const data = Object.fromEntries(formData);

  const span = trace.getActiveSpan();
  span?.setAttribute('app.action.input', JSON.stringify(redact(data)));

  // ... action logic
}

Same pattern: redact, attach to active span, then process.

Pages Router (legacy)

For projects still on Pages Router:

// pages/api/users.ts
import { NextApiRequest, NextApiResponse } from 'next';
import { trace } from '@opentelemetry/api';
import { redact } from '@/lib/redact';

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  const span = trace.getActiveSpan();
  if (req.body) {
    span?.setAttribute('http.request.body', JSON.stringify(redact(req.body)));
  }
  // ... handler
}

Headers and query params

Request bodies aren't the only place secrets leak. Headers (Authorization, Cookie) and query strings (?api_key=...) need the same treatment:

function redactHeaders(headers: Headers) {
  const result: Record<string, string> = {};
  headers.forEach((value, key) => {
    const lower = key.toLowerCase();
    result[key] = (lower === 'authorization' || lower === 'cookie' || lower.startsWith('x-api-key') || isOtpField(key))
      ? '[REDACTED]'
      : value;
  });
  return result;
}

span?.setAttribute('http.request.headers', JSON.stringify(redactHeaders(req.headers)));

Size limits

Bodies > 8KB are rare for legitimate requests. Cap captured size to keep storage bounded:

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

The automatic option

Hand-rolling this in every route handler is repetitive. The SecureNow npm package wraps OpenTelemetry with auto-capture and redaction:

// instrumentation.ts
export async function register() {
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    try {
      const securenowNext = await import('securenow/nextjs');
      const registerSecureNow = securenowNext.registerSecureNow || securenowNext.default?.registerSecureNow;
      registerSecureNow({ captureBody: true });
      await import('securenow/nextjs-auto-capture');
    } catch (error) {
      console.error('[securenow] SecureNow did not start; the app is running without it:', error);
    }
  }
}

npx securenow init writes this file and the next.config settings SecureNow needs. That's the entire setup. Bodies are captured automatically across all Route Handlers, API routes, and Server Actions. The redaction list is configurable. Body capture is part of the one plan ($99 per app per month) and the 14-day free trial.

For Edge Runtime portions of your app (middleware, Edge Route Handlers), the auto-capture doesn't run — Edge Runtime restrictions prevent the heavy instrumentation. For those, hand-roll using the patterns above.

Verifying redaction

Test it:

const test = {
  email: 'user@example.com',
  password: 'should-not-leak',
  metadata: { token: 'should-also-not-leak', name: 'fine to log' },
};
console.log(redact(test));
// {
//   email: 'user@example.com',
//   password: '[REDACTED]',
//   metadata: { token: '[REDACTED]', name: 'fine to log' }
// }

If anything sensitive comes through unredacted, add the field name to SENSITIVE and re-test.

Frequently Asked Questions

Where do I capture request bodies in Next.js App Router?

Inside Route Handlers (`route.ts` files) — that's where you have direct access to the Request object. For Server Actions, capture happens in the action function itself.

Can I capture bodies via middleware?

Not directly. Next.js middleware runs in Edge Runtime and doesn't allow consuming the body without breaking downstream handlers. Capture in the route handler instead.

What about getStaticProps / getServerSideProps?

Pages Router patterns. Same redaction logic applies; capture in the handler before processing.

Is there an automatic option?

Yes — add the `instrumentation.ts` that `npx securenow init` writes (Node runtime) and the SDK auto-captures bodies with redaction across Route Handlers, API routes, and Server Actions.

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