Security

PII Leak Prevention: Stopping Sensitive Data From Reaching Logs and Telemetry

Every team that locks down its logging still watches PII resurface in error reports, traces, and query logs six months later. This guide is about why closing one observability surface doesn't close the others, and what has to change about how the rules get enforced, not just written.

9 min read · 8 Principles · Compliance & Regulation
Purple triangular prism block on a matching purple surface.

TL;DR

  • Logging a whole object or entity logs every field it will ever have, including the one added next sprint that nobody flagged.
  • A record ID identifies a person in your systems just as well as their email does - and only one of those replicates their identity into every log aggregator you use.
  • A logged credential is usable by anyone with log access, which in most organizations is a much larger group than anyone with production access.
  • Per-call-site discipline fails at some call sites, every time, at scale. Redaction only works reliably as a property of the logger itself.
  • An error handler that returns exception detail, or an error-reporting SDK left on default settings, leaks the exact data your logging policy just blocked, through a pipeline nobody was watching.
  • A metric label or trace attribute set to a personal identifier, or query logging with bound parameter values, reproduces the same leak logging already solved, on a different telemetry surface.
  • No redaction policy catches everything forever, so the values that get through need an expiration date, not indefinite retention.

What PII Leak Prevention Actually Protects

A PII leak into logs rarely surfaces as an incident on its own. It surfaces during a SOC 2 evidence request, a GDPR data subject access request, or the forensic review after an unrelated breach, when someone discovers that a field a customer asked to have deleted has been sitting in a searchable log index, an error aggregator, or an APM trace store for years, readable by every engineer, contractor, and support agent who ever had log access.

None of the seven principles below are really about developer carelessness. They're about the fact that every observability surface - logs, error reporters, metrics, traces - is built by default to capture more context than any single call site controls, and that gap is exactly what discipline alone can't close.

1. Log Fields, Not Objects

Somewhere in your codebase there's a line that logs a whole user or order object. It works fine today, because today the object only has a display name and a timezone. Six sprints from now, someone adds a taxId or a dateOfBirth column to that same model, and every log line that has ever serialized it starts leaking that field too, without anyone touching the logging call. Never pass a whole request body, response body, entity, or domain object into a logging call — log a hand-picked, reviewed set of fields, referenced by the record's identifier, every time.

ANTI-PATTERN — the whole entity goes into the log line
logger.info('user profile updated', { user });
// `user` is a Prisma `User` record — every column the ORM
// knows about, including any field added next sprint, gets serialized.
CORRECTED — an explicit, reviewed set of fields
logger.info('user profile updated', {
  userId: user.id,
  fieldsChanged: ['displayName', 'timezone'],
});

This makes call sites more verbose, and it puts a decision in front of whoever adds a new field to the model: is this one safe to log. That friction is the point - a log statement that can't silently absorb new sensitive fields can't quietly start leaking six months from now.

2. Identifiers Belong in Records, Not Log Lines

The same instinct that logs whole objects for debuggability also logs emails and phone numbers directly, on the theory that it's faster to grep a log for an address than to look up a user ID. The cost shows up somewhere that developer never looks: access logs, CDN logs, and reverse proxy logs record full request URLs by default, so a password-reset link built with an email in the query string puts that address into every logging system between the client and the application, at every hop.

ANTI-PATTERN — identifying value in the query string
GET /password-reset?email=alice@acme.com&token=9fA31c...
CORRECTED — an opaque, single-use token, no identifying value in the URL
POST /password-reset
{ "token": "9fA31c..." }
// resolved server-side to a record — no identifying
// value ever appears in the request line that gets logged

The same logic applies to CSP violation reports, webhook callback URLs, and any other place a request line gets built and then logged by infrastructure the application team never configured.

3. A Logged Credential Is a Compromised Credential

Credentials get a separate rule from other identifiers because the failure mode is worse: leaking an email is a privacy problem, leaking a session token is an active account takeover waiting for whoever reads the log next. The most common way this happens isn't a developer deliberately logging a password - it's a debug line that dumps the whole header collection or cookie jar during an integration issue, and gets left in past the incident it was written for. Log the principal and the outcome, never the credential that authenticated it.

CORRECTED — log the principal and the outcome
logger.info('request authenticated', {
  userId: req.user?.id,
  authMethod: 'bearer',
});

OWASP's Logging Cheat Sheet lists exactly this pattern — full header or cookie dumps left in past a debugging session — among the most common ways credentials end up somewhere they were never meant to be.

You lose the ability to replay a malformed header from general-purpose logs alone. That's worth losing. A team that genuinely needs raw header capture belongs in a separate, short-retention, access-controlled debug channel, not in logs every service reads from.

4. Redaction Belongs to the Logger, Not the Developer

The first three principles all rely on the same thing: someone, at every call site, remembering the rule, every time, forever, across every service and every refactor. That fails statistically, not because engineers are careless, but because discipline re-applied correctly at hundreds of call sites by dozens of people over years will fail at some fraction of them. The mechanism that holds is a redaction policy declared once, on the logging framework's configuration, covering known credential and personal-data field names - paired with a ban on any diagnostic path, like a direct console.log, that bypasses the framework entirely.

CORRECTED — redaction declared once, applied to every event
const logger = pino({
  redact: {
    paths: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.email', '*.ssn'],
    censor: '[REDACTED]',
  },
});

This is the same centralization argument our structured logging standards guide makes for log hygiene generally — here it's the specific mechanism that keeps PII out.

Someone now owns the field-name list, and that list rots the moment a new service names the same concept contactInfo instead of email. That's a real cost - it's far cheaper than re-auditing every call site every time a new field ships.

5. Errors Leak the Data Logging Just Blocked

A team can get every logging call site right and still leak through the door right next to it. The exception handler returns the raw exception message straight to the API caller, or the error-reporting SDK gets installed on default settings, which capture the full request body, cookies, and local variable values and ship them to a third-party service that never went through the redaction policy. Callers get a generic message and a correlation ID; exception messages never get a field value interpolated into them.

ANTI-PATTERN — the field value is baked into the message
throw new ValidationError(`Invalid national ID: ${nationalId}`);
// logged, forwarded to the error aggregator, and often
// surfaced directly in support tooling
CORRECTED — identify by field name and record id
throw new ValidationError('Invalid nationalId format', { userId: user.id });

Diagnostic worth running: check sendDefaultPii on your error-reporting SDK - if it's unset or true, and beforeSend doesn't strip cookies and auth headers, your error pipeline is shipping exactly what your logging policy blocks.

6. Telemetry Repeats Every Mistake Logging Already Made

Metrics and traces get built after logging and error handling are already in decent shape, which is why they tend to repeat the same mistakes with a straight face. Setting a span attribute to a user's email feels like a debugging convenience, not a policy violation, right up until someone realizes the APM backend indexes every attribute value under a different retention policy than the primary datastore. The same happens with query logging: a team turns on bound-parameter capture to debug one slow query, and every value bound to every parameterized query since then sits in the observability backend.

Diagnostic worth running: grep your trace backend for high-cardinality attributes named email, name, or phone - if you get hits, personal data is sitting in a system with weaker access controls than your primary datastore.

OpenTelemetry's own semantic conventions for database instrumentation say the same thing directly: bound parameter values shouldn't be captured by default, because they may contain PII.

7. Retention Limits Bound the PII You'll Still Miss

Every principle above reduces how much PII reaches your logs, errors, and telemetry. None of them reduce it to zero, and treating any of them as if they could is the actual mistake. A library instruments something on its own initiative, a field gets added the week a redaction review was skipped, a debug statement ships by accident and survives three deploys. The real failure isn't that PII occasionally reaches an observability system - it will, repeatedly. The failure is that nobody put an expiration date on it.

Rule of thumb: if a log group, index, or sink doesn't have an explicit retention period set, its actual retention policy is indefinite, whether or not anyone chose that on purpose.

This is the setting an individual team, deciding well for itself, still gets wrong at the org level — GDPR's Article 32 and NIST's own log management guidance both treat retention as a deliberate control, not a platform default, for exactly this reason.

How Pandorian Enforces This Guide

Every principle above survives a design review. Few survive six months of independent team decisions, because the violations aren't visible from inside any one pull request - they're visible only in the aggregate. Pandorian enforces this guide across three surfaces:

Caught at PR level

A new logging call that serializes a whole request object, an error-reporting SDK left without PII scrubbing, a span attribute set to an email - each is a checkable code shape, flagged with the line, the rule, and a fix before it merges.

Full scans

New code discipline doesn't retroactively fix the service that's been logging entity objects since before this guideline existed. A full scan establishes the baseline: every existing call site that logs a whole object, every header dump, every query-logging config that already captures bound parameters, every log group without a retention period.

Scheduled scans

The leaks that matter most are rarely introduced through a reviewed pull request. A shared middleware default gets flipped to log request bodies and never flipped back. An SDK upgrade quietly changes a default capture setting. Scheduled scans catch drift PR checks, by construction, never see.

A Taste of the Guideline Package

Four of the checks that ship in this package - each runs at the PR level, on full or scheduled scans, or several at once.

RULE WHAT IT FLAGS
SEC_44381
A logging call passed a full request or response body, or a deserialized payload object, instead of individually selected fields.
SEC_74950
A logging call, string interpolation, or debug statement that includes an authorization header, cookie header, token, or password.
SEC_67035
Database or ORM query logging configured to capture bound parameter values instead of the parameterized statement shape.
OBS_82471
A log group, index, or sink defined in infrastructure-as-code without an explicit retention period.

FAQ

What is PII leakage in logs and telemetry?

+

PII leakage in telemetry is personal data ending up in logs, error reports, traces or metrics, where it lands in systems with different access controls and much longer retention than the database it came from. It usually arrives indirectly: a serialized request object, an exception message containing the input that caused it, or a trace attribute holding a full URL with an email in the query string.

The reason it is treated as its own discipline is scope. Your database has row-level controls and a retention policy. Your log platform is readable by every engineer on call and keeps data for a year.

What is the difference between redaction, masking, hashing and tokenization?

+

Redaction removes the value entirely, masking replaces part of it while keeping a recognizable shape (j***@acme.com), hashing replaces it with a deterministic irreversible digest, and tokenization swaps it for a random reference that can be reversed only through a separate vault. They trade debuggability against exposure in that order.

Choose by what the log is actually for. If the field exists so an engineer can correlate events, a hash or token preserves that. If it exists because someone serialized an object without thinking, redact it.

What counts as personal data under GDPR in a log file?

+

More than most engineers expect. GDPR defines personal data as any information relating to an identified or identifiable person, which covers IP addresses, device identifiers, cookie IDs, user agent strings in combination, and internal user IDs that can be resolved back to a person. A log line does not have to contain a name to be personal data.

Two consequences follow for telemetry: pseudonymized identifiers are still personal data and still in scope, and a data subject deletion request covers your logging platform, not just your production database. This is general information rather than legal advice, and your DPO or counsel should confirm scope for your jurisdiction.

Is hashing an email address enough to make it non-personal data?

+

Usually not. An unsalted hash of an email is trivially reversible by anyone with a list of candidate emails, which means it functions as a pseudonym rather than as anonymization. Under GDPR, pseudonymized data remains personal data and stays in scope for access and deletion rights.

Hashing is still worth doing, because it changes what a leak of the log itself exposes and preserves the ability to correlate events for the same user. Just do not let it change your compliance posture. If the goal is genuine anonymization rather than reduced exposure, a salted keyed hash where the key is rotated and held separately gets closer, and dropping the field entirely gets you all the way.

How do I find PII already sitting in our logs?

+

Query the log platform rather than reading the code, because the code is where you would have caught it already. Run regex searches across the last 30 days for email patterns, national ID formats relevant to your markets, credit card patterns, bearer tokens and long base64 strings. Then check the highest-volume error types, since stack traces carry the inputs that caused them.

Two places that are almost always missed: trace and span attributes, where full URLs with query strings get captured automatically by instrumentation, and third-party error trackers, which serialize local variables at the point of failure by design.

Can't a DLP tool or a log scrubber catch this?

+

A scrubber at the ingestion layer catches the patterns it knows, after the data has already left your process and crossed a network boundary. It matches emails and card numbers reliably, and misses everything shaped like your own domain: an internal customer reference, a free-text support note, a serialized object whose field names it has never seen. And it does nothing about the third-party SDK reporting directly to a vendor without passing through your pipeline.

Preventing the write is a different control from filtering the read. It means a rule on what may be passed to a logger, an error reporter or a span attribute, checked on every pull request, with full scans finding the call sites that already violate it.

Get the Guideline Package

You'll get this as a guideline MD file - hand it straight to your own coding agent or CI setup. Or skip the DIY step: give these same guidelines real enforcement superpowers, with PR checks and scans live on your repos, through Pandorian.

From Principles to Enforcement

Every rule in this guide is easy to agree with in a design review and easy to violate six sprints later, one locally-correct decision at a time. Pandorian turns boundary discipline like this into CI guardrails - scoped to the repos, teams or services you choose.