← Alle Neuigkeiten
data-exposure4 Min. Lesezeit

PII in logs: the data exposure bug you almost certainly have

Von Seaworthy · 2. April 2026

A set of papers with a magnifying glass laid on top, suggesting document inspection and data scrutiny

Photo by Caleb Martin on Unsplash

I want to tell you about the most boring-sounding data breach vector that keeps actually happening. Not a sophisticated zero-day. Not a supply chain attack. Just console.log(req) sitting quietly in a request handler, dumping email addresses, session tokens, and IP addresses into your log aggregator, day after day, until someone notices or a regulator asks questions.

This is so common it barely registers as a mistake anymore. That's the problem.

How it happens

AI-generated code, and honestly most human-written code too, logs entire objects constantly. It's the fastest way to debug something. You're not thinking about what's in req.body when you're trying to figure out why a webhook is failing at 11pm.

But req in an Express app contains a lot. The headers include Authorization tokens and cookies. req.body might carry the user's password (in plain text if this is a login endpoint), their email, their payment method details. req.user on an authenticated route contains everything your ORM pulled from the database.

// This looks innocent. It is not.
app.post('/checkout', async (req, res) => {
  logger.info('Checkout request received', { req })
  // ...
})

logger.info('Checkout request received', { req }) just serialised the entire request object. Pino, Winston, and Bunyan will all happily JSON-stringify whatever you hand them. Your log aggregator now has a permanent record of every card number, every email, every session cookie that passed through that endpoint.

Errors are just as bad:

try {
  await chargeCard(user, paymentDetails)
} catch (err) {
  logger.error('Charge failed', { err, user, paymentDetails })
  // paymentDetails is now in your logs forever
}

The fields that carry PII

If you grep your codebase for what gets passed to log calls, the usual culprits are: email, password, token, session, ip, creditCard, cardNumber, ssn, dob, phone, address. These aren't obscure fields. They're the ones your app runs on.

IP addresses are often forgotten. Under GDPR, IP addresses are personal data. If you're logging req.ip and retaining those logs for 90 days (common), you have a processing obligation you may not have documented.

Payment fields are the most dangerous. PCI-DSS prohibits storing full card numbers even in encrypted form outside a compliant vault. Logging paymentDetails wholesale is a direct PCI violation, and incident response for a card data exposure is substantially more expensive than any other kind.

Structured logging with field-level redaction

The fix is not to stop logging. It's to log intentionally, with an explicit allowlist of safe fields.

Pino has a redact option that strips fields before they reach any transport:

import pino from 'pino'

const logger = pino({
  redact: {
    paths: [
      'req.headers.authorization',
      'req.headers.cookie',
      'req.body.password',
      'req.body.cardNumber',
      'req.body.cvv',
      'user.passwordHash',
      'user.email',
    ],
    censor: '[REDACTED]',
  },
})

// Now safe to log the request: sensitive fields are stripped at serialisation time
app.post('/checkout', async (req, res) => {
  logger.info({ req }, 'Checkout request received')
})

This is better than nothing, but it's still denylist thinking. You're betting that you've enumerated every sensitive field. A better model is an allowlist serialiser that only emits what you explicitly say is safe:

import pino from 'pino'

const safeReqSerializer = (req) => ({
  method: req.method,
  url: req.url,
  statusCode: req.statusCode,
  // nothing else
})

const safeUserSerializer = (user) => ({
  id: user.id,
  role: user.role,
  // no email, no name, no address
})

const logger = pino({
  serializers: {
    req: safeReqSerializer,
    user: safeUserSerializer,
  },
})

Now logger.info({ req, user }, 'Request handled') only emits method, url, statusCode, id, and role. Everything else is silently dropped. When a new field is added to the user object, it doesn't automatically appear in logs.

Winston has a similar pattern via format.printf or a custom format that filters the meta object before writing.

Scrubbing existing logs

If you already have PII in your log store, you can't un-ring that bell, but you can stop new PII from accumulating while you figure out what to do about the historical data.

In Loki, a pipeline stage with a replace transformer can mask patterns in newly ingested logs:

- replace:
    expression: '("email":\s*")[^"]+"'
    replace: '"email": "[REDACTED]"'

In Datadog, sensitive data scanner rules let you define regex patterns that are applied at ingest. The data still enters the pipeline but is masked before it lands in the index. This doesn't fix the archive, but it stops the bleeding.

For historical data, you're looking at a manual review process and, if sensitive data is confirmed present, a potential obligation to notify your DPA depending on the volume and nature of the data exposed.

What GDPR and CCPA actually require

Under GDPR Article 5(1)(e), personal data must not be kept longer than necessary. Logs that accumulate PII indefinitely fail this test on two counts: you're storing personal data you didn't intend to collect, and you almost certainly have no retention policy that justifies keeping it.

CCPA adds a disclosure obligation: consumers can ask what personal data you hold. If the answer is "it's somewhere in 18 months of application logs" that is not a good answer to give to a regulator.

Data minimisation is the principle both regimes lean on. Collect only what you need, retain it only as long as you need it. A logging architecture that serialises entire request objects is the opposite of that.

The code fix is genuinely simple. The allowlist serialiser above takes about 20 minutes to write and deploy. The reason it doesn't happen is that nobody thinks to audit log calls during code review, because the output is not in the repo and the damage is not visible until something goes wrong.

Seaworthy flags this pattern automatically.

Dieser Artikel wurde von KI generiert und fasst öffentlich verfügbare Quellen zusammen.

Von den Machern von Seaworthy

Seaworthy scannt dein Repository nach den Problemen, die in Artikeln wie diesem behandelt werden.

Sicherheitslücken, offengelegte Geheimnisse und Fehlkonfigurationen — erkannt, bevor du bereitstellst. Kostenlos, kein Konto nötig.

$ npx seaworthycode