Skip to content
TACKO

Developer guide · Node.js logging

How to Redact PII from Logs in Node.js (and What Regex Can’t Catch)

Short answer: we scrub log data in the process, before it is shipped to a log store or error tracker. Key-based redaction covers the fields we know (password, email, authorization); pattern-based scrubbing covers free text we don't control (error messages, URLs, stack traces). Both are best-effort: no pattern matcher finds names, addresses or unknown secret formats, so the most reliable fix is still to log less.

We built @tacko/pii-scrub for the pattern-based part: free, MIT-licensed, zero dependencies, Node 18+, ESM and CJS with TypeScript types. Every example below was run against version 0.1.1 (the current release) and the output is pasted as printed.

Where PII leaks into logs

Most leaks are not someone logging user.email on purpose. They come from data that travels along unnoticed:

  • Error messages. An email interpolated into new Error() ends up in message and again in stack.
  • Headers. Logging a whole request logs Authorization and Cookie.
  • Query strings. /reset?email=…&token=… lands in access logs and in URLs inside errors.
  • JSON bodies. A failed signup logged for debugging includes the password field.
  • Custom error properties. Fields like err.customerEmail are serialised by most loggers.

Scrub before shipping, not only at the log store

Many log platforms can mask fields after ingestion. That helps, but by then the data has left the process and crossed the network. We prefer to scrub where the log line is built and keep store-side masking as a second layer.

Pattern-based scrubbing in TypeScript

import { scrubText, scrub } from "@tacko/pii-scrub";

console.log(scrubText("Payment failed for jane@example.com, card 4111 1111 1111 1111"));
console.log(scrubText(
  "GET /reset?email=jane@example.com&token=abc123def456 Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.c2lnbmF0dXJl"
));

const body = {
  user: { email: "jane@example.com", password: "hunter2", plan: "pro" },
  headers: { cookie: "sid=s3cr3t", "user-agent": "curl/8" },
};
console.log(JSON.stringify(scrub(body)));
console.log(body.user.password); // the original is not mutated

Output:

Payment failed for [email], card [card]
GET /reset?email=[email]&token=[redacted] Authorization: [bearer-token]
{"user":{"email":"[email]","password":"[password]","plan":"pro"},"headers":{"cookie":"[cookie]","user-agent":"curl/8"}}
hunter2

scrubText handles free text. scrub deep-scrubs strings, arrays, plain objects, Maps, Sets and Errors, redacts values under sensitive keys and returns a copy without mutating the input. Placeholders are typed ([email], [card], [password]), so the log still says what kind of value was there. Card numbers are only replaced when they pass the Luhn check and IBANs only when the mod-97 checksum is valid, to keep false positives down.

Error objects and stack traces

scrubError returns a new Error with the same prototype and scrubs the message, stack, cause and own properties:

import { scrubError } from "@tacko/pii-scrub";

class PaymentError extends Error {
  constructor(message: string, public customerEmail: string) {
    super(message);
    this.name = "PaymentError";
  }
}

const err = new PaymentError("Charge declined for jane@example.com", "jane@example.com");
const safe = scrubError(err);
console.log(safe instanceof PaymentError, safe.message, safe.customerEmail);
console.log(safe.stack?.split("\n")[0]);

Output:

true Charge declined for [email] [email]
PaymentError: Charge declined for [email]

Because the prototype is kept, error handling that checks instanceof keeps working.

pino integration

pino has built-in redaction by path:

import pino from "pino";

const logger = pino({
  redact: { paths: ["req.headers.authorization", "user.email"], censor: "[redacted]" },
  base: undefined,
  timestamp: false,
});
logger.info(
  { user: { email: "jane@example.com" }, req: { headers: { authorization: "Bearer abc" } } },
  "login for jane@example.com"
);

Output:

{"level":30,"user":{"email":"[redacted]"},"req":{"headers":{"authorization":"[redacted]"}},"msg":"login for jane@example.com"}

Note the message: path-based redaction does not look inside free text. To cover messages and errors too, we scrub the arguments in hooks.logMethod:

import pino from "pino";
import { scrub, scrubText } from "@tacko/pii-scrub";

const logger = pino({
  hooks: {
    logMethod(args, method) {
      method.apply(
        this,
        args.map((a) => (typeof a === "string" ? scrubText(a) : scrub(a))) as Parameters<typeof method>
      );
    },
  },
  base: undefined,
  timestamp: false,
});
logger.info(
  { user: { email: "jane@example.com" }, err: new Error("token=abc123def456 leaked") },
  "signup failed for jane@example.com"
);

Output:

{"level":30,"user":{"email":"[email]"},"err":{"type":"Error","message":"token=[redacted] leaked","stack":"Error: token=[redacted] leaked\n    at ..."},"msg":"signup failed for [email]"}

The two combine well: redact for the paths we know, the hook for everything else. (The stack in the output above is shortened.)

winston integration

In winston, a custom format can scrub each entry. scrub() returns a new object, and winston keeps internal state on Symbol keys of info; returning the copy directly printed nothing in our test, so we copy the scrubbed fields back:

import winston from "winston";
import { scrub } from "@tacko/pii-scrub";

// scrub() returns a copy; winston keeps state on Symbol keys, so copy the clean fields back onto info
const scrubFormat = winston.format((info) => Object.assign(info, scrub({ ...info })));

const logger = winston.createLogger({
  format: winston.format.combine(scrubFormat(), winston.format.json()),
  transports: [new winston.transports.Console()],
});
logger.info("reset link sent to jane@example.com", { phone: "+386 41 123 456" });

Output:

{"level":"info","message":"reset link sent to [email]","phone":"[phone]"}

Your own formats

For IDs that only exist in our system, we add a custom detector. IP addresses are off by default; we turn them on where they count as personal data:

import { createScrubber } from "@tacko/pii-scrub";

const scrubber = createScrubber({
  customDetectors: [{ name: "orderRef", pattern: /\bORD-\d{6}\b/, replacement: "[order]" }],
  detectors: { ipv4: true },
});
console.log(scrubber.text("ORD-123456 from 203.0.113.7"));

Output:

[order] from [ip]

createScrubber compiles the options once, which is cheaper than passing options on every call.

What regex can't catch

The same scrubber on inputs it is not built for:

import { scrubText } from "@tacko/pii-scrub";

console.log(scrubText("Ship to Jane Novak, Slovenska cesta 1, Ljubljana"));
console.log(scrubText("user=amFuZUBleGFtcGxlLmNvbQ=="));  // base64 of an email
console.log(scrubText("jane at example dot com"));
console.log(scrubText("order 4111111111111112 failed"));  // fails the Luhn check

Output:

Ship to Jane Novak, Slovenska cesta 1, Ljubljana
user=amFuZUBleGFtcGxlLmNvbQ==
jane at example dot com
order 4111111111111112 failed

None of these are redacted, which is expected from any pattern-based tool:

  • Names and addresses have no reliable shape.
  • Encoded data (base64, URL-encoded, hashed or compressed values) hides the pattern.
  • Obfuscated or free-form text, like “jane at example dot com” or support-ticket prose.
  • Unknown or short secret formats. Detection relies on known prefixes, lengths and structure, so some key formats slip through. We add custom detectors for the ones we use and test with realistic values.
  • Validation trade-offs. Requiring a Luhn or checksum pass reduces false positives, but a mistyped card number stays in the log.

Limitations and what to do instead

@tacko/pii-scrub is best-effort redaction. As its README says, it is not a compliance tool and does not make logs GDPR, HIPAA or PCI compliant. We use it as one layer:

  1. Log less: log IDs, not whole objects, and don't log request bodies by default.
  2. Redact known fields by key in the logger (redact, sensitiveKeys).
  3. Scrub free text (messages, errors, URLs) before shipping.
  4. Mask at the log store as a backup and keep retention short.
  5. Test with realistic sample data, including the formats we actually use.

Get the package

npm install @tacko/pii-scrub
@tacko/pii-scrub on npmSource and issues on GitHub

If it misses a format you use, we'd welcome an issue. More guides.