jguillaumesio
gdprsecurityarchitecture

PII and data masking: how to stop leaking personal data everywhere

Personal data spreads into logs, staging, analytics, and exports, far beyond the users table. A practical guide to masking, pseudonymization, and anonymization, and knowing which one you actually did.

The hard part of protecting personal data is not the users table. Everyone knows that holds PII. The problem is everywhere else it quietly ends up: an email address in an error log, a full production database copied into staging, a customer name in an analytics event, a phone number in a support export, a request body pasted into an LLM prompt.

PII (personally identifiable information) leaks by default, because copying data is convenient and nobody stops to ask whether the copy still needs to be personal. This is a practical guide to masking it: the techniques, where to apply each, and the one distinction that decides whether you have actually reduced your risk or just moved it.

This pairs with the guide on GDPR data retention and deletion. That one is about how long you keep data and how to delete it. This one is about making the copies you do keep stop being personal data in the first place.

Usual disclaimer: I am an engineer, not a lawyer. This is implementation guidance, not legal advice.

The distinction that actually matters

Three words get used interchangeably and are not the same thing. Getting them straight is the whole game, because they have different legal consequences under GDPR.

  • Masking / redaction: replacing the value with something non-sensitive, usually irreversibly, for display or storage. john@acme.com becomes j***@acme.com or [REDACTED]. The original is gone from that copy.
  • Pseudonymization: replacing identifiers with a reversible token, where a separate key can map back to the real value. john@acme.com becomes user_8f3a, and a lookup table or key can reverse it. GDPR encourages this, but it is critical to understand: pseudonymized data is still personal data. You still hold the mapping, so it is still in scope for retention, erasure, and breach rules.
  • Anonymization: transforming data so that no one, including you, can link it back to a person, even by combining it with other data. Truly anonymized data is no longer personal data and falls outside GDPR. This is much harder than it sounds, because re-identification from “anonymous” datasets is a well-documented attack.

The trap is to do pseudonymization, call it anonymization, and assume you are out of scope. You are not. If you kept a way back, it is pseudonymization, and the data is still regulated. Only reach for “anonymized” when you have genuinely thrown away the ability to re-identify.

Where to mask: static vs dynamic

There are two moments you can mask, and they solve different problems.

Static masking transforms data at rest, producing a sanitized copy. This is the one for non-production environments. The classic GDPR failure is cloning the production database into staging so developers have “realistic data”, which means every engineer, every CI run, and every less-secured staging box now holds real customer PII. Instead, mask on the way out:

-- build a staging dump with names and emails masked
UPDATE users SET
  email = 'user' || id || '@example.com',
  full_name = 'User ' || id,
  phone = NULL;

Better still, generate a synthetic seed that looks real but describes no actual person, so staging never contains production PII at all. Either way, real production data in a non-production environment is the single most common avoidable exposure.

Dynamic masking transforms data at read time, based on who is asking. A support agent sees **** 4242, a payment service sees the full number. The stored value is intact; the masking happens in the query layer or the API response. Use this when different roles legitimately need different views of the same record, and never rely on the frontend to do it: mask server-side, because anything the client hides, the client can also reveal.

Masking techniques, and their pitfalls

Not all masking is equal, and the naive version breaks things:

  • Random masking replaces each value with a random one. Safe, but it destroys relationships: the same user gets different masked values in two tables, so your staging data no longer joins.
  • Deterministic masking maps each input to the same output every time (usually a keyed hash). john@acme.com always becomes user_8f3a, everywhere. This preserves joins and uniqueness, which is what makes masked data actually usable. The catch: deterministic masking is pseudonymization, not anonymization, and it is vulnerable to correlation, so keep the key secret and treat the output as still-personal.
  • Format-preserving masking keeps the shape so validators and column types still pass: a masked credit card is still 16 digits, a masked email still has an @. Necessary when downstream code checks format.

Do not log PII in the first place

Logs deserve their own section because they are the leak nobody notices until a breach. An email in a stack trace, an IP in an access log, a full request body captured “temporarily” for debugging: all of it is personal data, sitting in a system designed to be append-only and widely readable.

You will not run per-user deletes across a log firehose, so the control has to be at write time:

  • Structured logging with an allowlist. Log explicit fields, never whole objects. logger.info({ userId, action }), not logger.info(user), which dumps the email, name, and everything else the moment someone logs the object.
  • A redaction layer. Most logging libraries support redaction paths that strip known-sensitive keys (password, email, authorization, token) before anything is written. Configure it once, centrally.
  • Detection for free text. Structured redaction misses PII embedded in free-form strings (an email typed into a message body). For that, a detection library like Microsoft Presidio can find and redact common PII patterns (emails, phone numbers, names) before logging or before data leaves your system.

The cheapest control by far is not writing the PII down. Redaction after the fact is always more expensive and never complete.

A practical approach

You do not need a data-masking platform to start. In order of impact:

  1. Stop cloning prod into non-prod. Mask the dump, or seed synthetic data. This kills the biggest exposure immediately.
  2. Redact PII at the logging layer. Allowlist fields, strip sensitive keys, add free-text detection where you log user content.
  3. Use deterministic masking when masked data must stay joinable, and remember it is still pseudonymized (keep the key safe).
  4. Mask dynamically for role-based views, always server-side.
  5. Be honest about anonymized vs pseudonymized. If you kept a way back, it is still personal data, with all the retention and erasure duties that implies.

None of this is exotic. It is mostly deciding, per copy of the data, whether that copy needs to identify a real person, and if not, removing the ability for it to do so. The systems that get breached are rarely breached through the users table. They are breached through the fourth staging box and the two-year log archive that everyone forgot were full of real people.