Skip to main content
Saved

Pattern Security Advanced

PII Redaction

You can strip or mask sensitive data just before it leaves your app for a third-party service, so information you never meant to send doesn’t leak.

Examples
native web
Read
6 min

The problem

A form submission fails, your error reporter captures the error, and the event includes the full request body, with the user’s email, their plaintext password, a credit card number, and a session token. None of that was supposed to leave the browser, but now it’s stored indefinitely in a third-party dashboard that dozens of people can search.

The pattern

You should redact PII at the boundary, which is the moment data is about to leave your app, instead of trying to clean it up later in each system it was sent to. Each place that sends data out, whether that’s your logger, your analytics wrapper, or your error reporter, should pass its payloads through the same redactor before sending them. One shared redactor helps, because if each integration has its own scrubbing code, any integration your team forgets about will leak data.

Problem

This can happen all over an app, and you usually won’t notice it. Analytics events might send user identifiers and form payloads to Mixpanel or Google Analytics. Console logs that someone added for a quick debugging session might never get removed, so they keep sending emails and phone numbers to a log aggregator. Exception monitoring might capture stack traces along with request parameters that contain auth tokens, and a support engineer might copy a log line with a customer’s address in it into a Slack thread. Each of these seems harmless when it happens, but together they spread personally identifiable information (PII) across systems you don’t fully control.

If a logging platform gets breached, attackers might get a ready-made collection of your users’ PII, gathered from your own telemetry. A compliance audit might find sensitive data spread across systems that have no access controls or retention policy, and that can expose the company to GDPR fines and CCPA penalties. This kind of data leaks easily, because the team never meant to send it, so they aren’t checking for it.

Solution

You’re better off with an allow-list than a deny-list. If you list the fields that are safe to send, such as userId, route, and statusCode, and mask everything else, the redactor fails closed. A new field that someone adds next quarter gets redacted by default, instead of being sent without anyone noticing. A deny-list of known sensitive keys is useful as a second layer, but it can only block the fields you’ve already thought of, so it misses new ones.

For free text, where there aren’t any field names to check, you can fall back to pattern matching. Regular expressions can mask emails, phone numbers, credit card numbers, and strings that look like tokens inside log messages and stack traces, replacing them with placeholders like [EMAIL] or [CARD]. Patterns miss some values and match some they shouldn’t, so they work best as a safety net behind field-level redaction, and not as your main protection.

You then add the redactor to every place where data leaves the app. A hook on your error reporter, such as Sentry’s beforeSend, can scrub the request body and headers before the event is sent, and a wrapper around analytics calls can filter properties the same way. In your structured logger, field-level redaction based on the allow-list stops a password or creditCard key before it gets to the transport. You can keep real values in local development and enforce redaction in staging and production. The redacted data still has the same field names and structure, which is usually what you need for debugging, but without the sensitive values.

Example

These examples build one reusable redactor, then send an error reporter’s events and a structured logger’s records through it. The redactor is the only code that defines what counts as sensitive, and everything else calls it.

A reusable redactor

The core redactor deep-clones the input so the original object is never mutated. It masks any key on the sensitive list and runs regex patterns over string values to catch PII embedded in free text. You can also pass an optional allow-list to make it fail closed, so only the keys you’ve marked as safe keep their values.

// Lowercase, so 'Authorization' and 'authorization' both match.
const SENSITIVE_KEYS = new Set([
  'password', 'pwd', 'token', 'accesstoken', 'refreshtoken',
  'authorization', 'cookie', 'ssn', 'creditcard', 'cardnumber',
  'cvv', 'email', 'phone',
]);

const PATTERNS = [
  [/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '[EMAIL]'],
  [/\b(?:\d[ -]*?){13,16}\b/g, '[CARD]'],
  [/\b\d{3}[-.\s]?\d{3}[-.\s]?\d{4}\b/g, '[PHONE]'],
  [/\b[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, '[TOKEN]'],
];

function redactString(value) {
  return PATTERNS.reduce((acc, [pattern, label]) => acc.replace(pattern, label), value);
}

// allowList: if provided, only these keys survive (fail-closed).
function redact(input, { allowList } = {}) {
  if (typeof input === 'string') return redactString(input);
  if (Array.isArray(input)) return input.map((item) => redact(item, { allowList }));
  if (input && typeof input === 'object') {
    const output = {};
    for (const [key, value] of Object.entries(input)) {
      if (allowList && !allowList.has(key)) {
        output[key] = '[REDACTED]';
      } else if (SENSITIVE_KEYS.has(key.toLowerCase())) {
        output[key] = '[REDACTED]';
      } else {
        output[key] = redact(value, { allowList });
      }
    }
    return output;
  }
  return input;
}

export { redact };

Scrubbing error reports

Error reporters capture far more than the error itself, including request bodies, headers, breadcrumbs, and any context you attach. Sentry’s beforeSend hook runs on every event right before it leaves the browser, which makes it a good place to run the whole payload through the redactor.

import * as Sentry from '@sentry/browser';
import { redact } from './redact';

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  beforeSend(event) {
    // Scrub the request the user was making when it failed.
    if (event.request) {
      event.request.data = redact(event.request.data);
      event.request.headers = redact(event.request.headers);
      delete event.request.cookies;
    }

    // Scrub any extra context attached by the app.
    if (event.extra) {
      event.extra = redact(event.extra);
    }

    // Scrub breadcrumb data, which often mirrors network payloads.
    if (event.breadcrumbs) {
      event.breadcrumbs = event.breadcrumbs.map((crumb) => ({
        ...crumb,
        data: crumb.data ? redact(crumb.data) : crumb.data,
      }));
    }

    return event;
  },
});

Field-level redaction in logs

The allow-list is most useful in structured logging. Instead of relying on developers to never log a sensitive field, the wrapper keeps only the keys on the safe list and redacts the rest before the record is written, so a stray password in a context object never gets to the transport.

import { redact } from './redact';

// Keys that are always safe to emit. Everything else is redacted.
const SAFE_KEYS = new Set([
  'level', 'message', 'timestamp', 'requestId', 'userId',
  'route', 'method', 'statusCode', 'durationMs',
]);

function write(level, message, context = {}) {
  const record = {
    // Fail closed: only allow-listed keys survive, and their
    // string values still pass through pattern matching.
    ...redact(context, { allowList: SAFE_KEYS }),
    level,
    message: redact(message),
    timestamp: new Date().toISOString(),
  };

  // Single exit point: the only place log records leave the app.
  process.stdout.write(JSON.stringify(record) + '\n');
}

const logger = {
  info: (message, context) => write('info', message, context),
  warn: (message, context) => write('warn', message, context),
  error: (message, context) => write('error', message, context),
};

logger.error('Checkout failed', {
  userId: 'u_123',
  route: '/checkout',
  email: 'user@example.com', // redacted: not in SAFE_KEYS
  cardNumber: '4111 1111 1111 1111', // redacted: not in SAFE_KEYS
});
// {"userId":"u_123","route":"/checkout","email":"[REDACTED]","cardNumber":"[REDACTED]",
//  "level":"error","message":"Checkout failed","timestamp":"..."}

export { logger };

Benefits

  • Sensitive values are stripped before they leave the app, so the PII that the redactor recognizes never ends up in third-party dashboards you don’t control.
  • With an allow-list, new fields are redacted by default instead of being sent unnoticed, so your users’ privacy doesn’t depend on someone remembering to block each new sensitive key.
  • Because every integration uses the same redactor, they all share one definition of what’s sensitive, and the team doesn’t have to keep separate scrubbing code in sync.
  • Redaction helps with GDPR and CCPA compliance by keeping regulated data out of systems with no access controls or retention policy.
  • If your logging or monitoring platform is breached, attackers get far less, because your telemetry no longer contains a ready-made collection of your users’ PII.
  • You can still debug from redacted records, because they keep the field names and the structure of the data.
  • Pattern matching catches PII inside free-text messages and stack traces, which field-level rules miss.
  • Redaction can be enforced in staging and production only, so you can still debug with real data on your own machine.

Tradeoffs

  • If you redact too much, you lose the information you need for debugging, and when every log line says [REDACTED], you have nothing to go on during an incident.
  • Regex patterns can miss real values and match harmless ones, so international phone formats and unusual emails might slip through while ordinary strings of digits get masked as card numbers.
  • Copying and scanning large payloads for each log call and error event takes CPU time, and that adds latency in code that runs often.
  • The sensitive-key list and the allow-list need regular updates, because a renamed or newly added field can leak past the sensitive-key list until someone updates it.
  • An allow-list takes discipline up front, because you have to list each field you need, or it disappears from your telemetry without any warning.
  • With one shared redactor, a single bug affects every integration at once, so the redactor needs good tests.
  • PII can end up in places the redactor doesn’t look inside, such as serialized JSON strings or base64 blobs within the payload.
  • Redaction works best alongside collecting less PII in the first place, since a value that never goes into a payload can’t leak.
  • Third-party SDKs might capture and send data through code your wrapper doesn’t intercept, so the redactor only covers data that goes through hooks you control.

Summary

With PII redaction, you scrub sensitive data just before it leaves the app, using an allow-list for structured fields and pattern matching for free text. If your error reporter and your structured logger both call one shared redactor, for example from Sentry’s beforeSend, they both follow the same rules. Redaction is a safety net, though, and collecting less PII in the first place is still the better protection.

See also

Next in Security SameSite Cookie The SameSite cookie attribute controls when browsers send cookies on cross-site requests, and it helps prevent CSRF attacks.

Newsletter

A monthly email
from Den Odell.

Behind-the-scenes thinking on frontend patterns, site updates, and more.

No spam. Unsubscribe anytime.