Capturing Request Bodies for Forensics in Express Without Leaking Secrets

Full request bodies make incident investigation 10x faster — and leak passwords, tokens, and PII if you do it naively. Here's the redaction-aware capture pattern for Express.

Lhoussine
May 9, 2026·7 min read

Capturing Request Bodies for Forensics in Express Without Leaking Secrets

Investigating a security incident without request bodies is like investigating a fire without seeing the room. You know the time, you know the location, you don't know what burned. The fix is body capture in your traces — but the naive version leaks every password, token, and credit card number to your log store.

Here's the production-grade body capture pattern with field-level redaction. For the broader observability context, see the application security monitoring guide.

The naive version (don't do this)

app.use((req, res, next) => {
  console.log({
    method: req.method,
    path: req.path,
    body: req.body, // <-- this is the bug
  });
  next();
});

What this leaks:

  • POST /auth/login body has { email, password } — passwords end up in your logs
  • POST /api/keys body has new API keys in plaintext
  • POST /api/payments body has card numbers
  • POST /api/users body has PII (SSN, phone, address)

Within a week, your log store contains every secret your users have entered. If your logs are ever breached or compromised, that's a much bigger story than whatever attack you were trying to investigate.

The redaction-first pattern

Define a list of sensitive field names. Recursively redact them before capture:

const SENSITIVE_FIELDS = 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',
  'national_id',
]);

function redact(obj, depth = 0) {
  if (depth > 10) return '[max-depth]';
  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));

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

Wire it into a middleware that captures the redacted body alongside the trace:

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

app.use(express.json({ limit: '1mb' }));

app.use((req, res, next) => {
  const span = trace.getActiveSpan();
  if (span && req.body && Object.keys(req.body).length > 0) {
    span.setAttribute('http.request.body', JSON.stringify(redact(req.body)));
  }
  next();
});

Now your traces show full request bodies for forensics, with sensitive fields replaced by [REDACTED]. Forensic value preserved, secrets protected.

Edge cases the naive version misses

Nested fields. A signup payload { user: { credentials: { password: 'foo' } } } needs recursive redaction. The implementation above handles this; many production codebases don't.

Array of objects. { orders: [{ card: '...' }, { card: '...' }] } — same, recurse through arrays.

Headers. The Authorization header, cookies, and X-API-Key all leak credentials. Capture and redact headers separately:

function redactHeaders(headers) {
  const result = {};
  for (const [key, value] of Object.entries(headers)) {
    const lower = key.toLowerCase();
    if (lower === 'authorization' || lower === 'cookie' || lower.startsWith('x-api-key')) {
      result[key] = '[REDACTED]';
    } else {
      result[key] = value;
    }
  }
  return result;
}

app.use((req, res, next) => {
  const span = trace.getActiveSpan();
  span?.setAttribute('http.request.headers', JSON.stringify(redactHeaders(req.headers)));
  next();
});

Query strings. URL parameters can also contain tokens (?api_key=...). Same redaction logic, applied to req.query.

What about response bodies?

Response bodies leak just as much, especially for endpoints that return user data. The pattern is the same but harder to wire into Express because you have to wrap res.send / res.json:

app.use((req, res, next) => {
  const originalJson = res.json.bind(res);
  res.json = function (body) {
    const span = trace.getActiveSpan();
    if (span && body) {
      span.setAttribute('http.response.body', JSON.stringify(redact(body)));
    }
    return originalJson(body);
  };
  next();
});

This wraps res.json to capture the response payload before it's sent. The redaction protects user data being returned (e.g., /api/me shouldn't log the user's full record).

Size limits

A 1MB request body in your trace data is expensive at scale. Add a size cap:

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

8KB per body is a reasonable default for forensics — enough to see the structure and the abuse, not enough to bloat your storage.

What you give up by redacting

Some forensic queries are blocked by redaction. "Show me everyone who tried to log in with the password 'admin123'" can't be answered if password is redacted. This is a feature, not a bug — that query is also a credential-stuffing tool if it falls into the wrong hands.

For password-related forensics specifically, hash the password before redacting: password: hash(value).substr(0, 8). Now you can find groups of attempts using the same password without exposing the password itself.

What this looks like with SecureNow

The SecureNow npm package does all of this automatically when you preload securenow/register. The redaction list is configurable, the size limits are tunable, and the captured bodies flow into the same dashboard as the rest of your spans. No middleware to write.

If you want hand-rolled control, the patterns above are the production-grade version. Either approach beats console.log(req.body).

Related

Frequently Asked Questions

Why capture request bodies?

Forensics. When investigating an attack or bug, the request body answers questions logs alone can't — what payload did they send, what fields did they tamper with, what coupon code did they try. Without bodies, every investigation hits a wall.

Isn't capturing bodies a security risk?

Yes if done naively. Passwords, API keys, credit card numbers, and PII end up in your logs. The fix is automatic redaction at capture time — never store the sensitive fields, never let them reach the log forwarder.

How does this differ from request logging?

Request logging usually captures method, path, status, and timing. Body capture adds the actual JSON payload. Both have their place; the trick is doing body capture safely.

What's the storage cost?

Request bodies add roughly 200B–5KB per request to your trace data. For an average web app at 1M requests/day, that's 200MB–5GB/day of additional data. Most of it compresses to ~10% of the raw size in column-oriented stores like ClickHouse.

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