DEV Community

Michele
Michele

Posted on

Choosing a PII anonymization strategy: when mask, redact, replace, hash, or substitute wins

Any tool that anonymizes PII from free text gives you roughly five levers:
mask, redact, replace, hash, and substitute. They all
answer one question — how much of the original value do you keep, and in what
form? The choice is rarely global. A support screen needs the last four of a
card to be recognisable; a log aggregator needs a stable join key that is not
the original email; a screenshot fixture for a compliance deck needs one
canonical placeholder, not a different synthetic value every run. Pick the
strategy per field, not per codebase. This article walks through each
strategy, what it does to the value, and a decision matrix for picking the
right one per use case.

Five PII anonymization strategies compared: mask, redact, replace, hash, substitute. The same input email, jane.doe@example.com, transformed by each strategy.

The diagram shows the same input — the email jane.doe@example.com — fed
through each of the five strategies. The rest of this article explains why
you would pick one over another and how to combine them in a single call.

Why the choice matters

The strategy you pick is the privacy/utility knob for that field. Strip
everything and you lose the diagnostic value of a log line; preserve
everything and you have not anonymized anything. A default that is right for
one team is rarely right for every team. A production log aggregator, a
development seed dataset, and a screenshot fixture for a compliance training
deck want three different things from the same email address. The rest of
this article is about recognising which case you are in and choosing the
strategy to match.

Mask

Mask keeps the structure of the value and hides part of it. You usually
pick it when the value still has to look like itself, just less complete.

anonymize(text, {
  EMAIL_ADDRESS: {
    type: "mask",
    char: "*",
    count: 5,
    fromEnd: false
  }
})
Enter fullscreen mode Exit fullscreen mode

A common default for credit cards is to keep the last four digits,
for IP addresses to keep the first three octets, and for MAC
addresses
to keep the OUI while hiding the rest.

Best for: support screens where an agent still needs the last four of a
card or a recognisable email shape to find a record; logs that must stay
greppable; and any UI that benefits from the user seeing a value is there
without showing the whole value.

Operational note: residual plaintext is still sensitive. Masking is a
usability tool, not a privacy boundary.

Redact

Redact is the simplest strategy: the detected value is removed entirely.
The configuration object is just { type: "redact" }.

anonymize("Alice Smith can be reached at asmith@mygoogle.com", {
  EMAIL_ADDRESS: { type: "redact" }
})
// → "Alice Smith can be reached at "
Enter fullscreen mode Exit fullscreen mode

It is a common default for URLs when you want to keep the base address
but drop query strings and other tracking material.

Best for: free-text fields where the PII carries no diagnostic value —
error messages, exception payloads, customer-support copy pasted into a
ticket. It is the minimum-friction option when you genuinely do not need the
value downstream.

Operational note: redact destroys all signal. If a downstream parser expects the
entity to be a non-empty string, redact will hand it an empty slot and may
crash on whitespace. Wrap it with a sentinel if your pipeline needs one.

Replace

Replace substitutes every occurrence of the detected entity with one
fixed value that you provide.

anonymize(text, {
  EMAIL_ADDRESS: {
    type: "replace",
    value: "dummy@example.com"
  }
})
Enter fullscreen mode Exit fullscreen mode

Replace is rarely the default for any entity. The value you supply is
global: every email in the request becomes dummy@example.com, every phone
number becomes +1-555-0100, and so on.

Best for: seed data for screenshots and compliance training decks;
demo fixtures that have to look like real data but must not be real;
synthetic test inputs where you want a single canonical placeholder rather
than a different synthetic value per occurrence.

Operational note: every detected entity collapses to one value. If your dataset
has a hundred email addresses, they all become dummy@example.com — so
uniqueness is destroyed by construction, and any downstream check that
relies on distinctness (duplicate detection, per-user correlation, group-by)
will produce nonsense. Replace is for display, not analysis.

Hash

Hash turns the detected value into a fixed-length token. Most
implementations use SHA-256 and, when a salt is provided, produce the same
token for the same input across calls.

anonymize(text, {
  salt: "0123456789abcdef",
  overrides: { IP_ADDRESS: { type: "hash" } }
})
Enter fullscreen mode Exit fullscreen mode

Hash is the common choice for crypto addresses and, in practice, a very
common choice for IP addresses in log sanitisation. Without a salt, each
request hashes independently and the same value can produce a different token
on every call; with a salt, the output is deterministic across calls and
across services.

Best for: cross-record correlation. If you need to trace one user
across dozens of log lines without storing the original value, hash with a
stable salt gives you the join key. The token is a stable handle for the
record — every log line that mentions the same email produces the same
token, so you can join records across services without ever seeing the
original.

Operational note: hash is a pseudonymisation technique. The salt is a
separate secret you must govern alongside the data — rotate it like any
other credential, restrict who can read it, and never log it next to the
hashes. If the link to the original is no longer needed, delete or rotate
the salt so the records can no longer be re-derived.

Substitute

Substitute swaps the detected value for realistic synthetic data of the
same type. Configuration is type and an optional attribute that hints
what flavour of synthetic data to produce.

anonymize(text, {
  salt: "0123456789abcdef",
  overrides: {
    EMAIL_ADDRESS: { type: "substitute", attribute: "email" }
  }
})
Enter fullscreen mode Exit fullscreen mode

When a salt is provided, the same input always produces the same synthetic
output across calls — every endpoint, every region, every CI run. With the
salt, substitute becomes a join key: the same jane.doe@example.com will
appear in every record that originally referenced that user, without
revealing anything about the original address. Without the salt, synthetic
values vary by request, so substitute is only useful when you don't need to
re-identify the same record later.

Substitute is the common default for person names (a fake name from the
same locale), locations (a synthetic address or state), phone
numbers
(a fake number in the same format), and email addresses (a
fake address with a plausible domain). It is the closest strategy to a
"functional but not real" copy of the original.

Best for: analytics pipelines that still need to group by user; dev
and staging environments seeded with realistic-looking data; datasets
shared with third parties where the receiving system has to keep working
but the subjects have to stay anonymous. Substitute preserves the
shape of the data, so downstream code, regexes, and validators all
behave as they would with real values.

Operational note: synthetic is not original. Substitute does not preserve
uniqueness, does not preserve relationships between fields (a substituted
name and a substituted email will not belong to the same fake person),
and on low-cardinality entity types (countries, currencies, states) the
synthetic value can collide with the real one in the same dataset. For
correlating records, use hash. For preserving function, use substitute.

Decision matrix — what each strategy does

Strategy Output shape Reversible to original? Format kept? Length kept? Joinable across records?
mask Partially redacted original n/a (partial plaintext remains) Yes (character class preserved) Configurable (char, count, fromEnd) Weak (the unmasked tail varies)
redact Empty (entity removed) No n/a n/a No
replace One fixed caller value No Yes (you choose) Yes No — every entity collapses to one value
hash Fixed-length hex (SHA-256, salted if a salt is set) Pseudonymisation — the salt is a separate secret you govern No (hex) Fixed (64 hex chars for SHA-256) Yes, with a salt
substitute Realistic synthetic value of the same type No (synthetic, not derivable) Yes (same type and locale) Approximately With a salt: same input → same synthetic output. Without: no.

Decision matrix — pick by use case

If you need to… Pick Why
Show a human the last four of a card, or a recognisable email shape mask Preserves the part of the value that humans use to recognise the record
Remove PII from a free-text log line and never need the value again redact Lowest-friction default; the entity disappears entirely
Trace one user across dozens of log lines without storing the original hash with a salt Deterministic token gives you a join key without keeping the value
Seed a dev environment or share a dataset with a third party without re-identification risk substitute Synthetic data keeps the pipeline functional, but the subject is fictional
Build a screenshot fixture for a compliance training deck replace One global placeholder is easier to read in a static image than a hash
Sanitise web-hook payloads from a payment provider before they reach your APM mask (card) + redact (everything else) Card last-four stays useful for support; everything else is noise
Default a new service to "no PII in logs" without bespoke per-entity config redact for free-text + mask for cards Two overrides cover most cases; expand from there as you find needs

Combining all five in one call

A real integration rarely uses one strategy. Most APIs let you key overrides
by entity type, so you can mix strategies per entity in a single call:

anonymize(
  "User Jane Doe (jane.doe@example.com) called from 203.0.113.42 about order #4711",
  {
    locale: "en_US",
    salt: "0123456789abcdef",
    overrides: {
      PERSON: { type: "substitute" },
      EMAIL_ADDRESS: { type: "mask", char: "*", count: 4, fromEnd: false },
      IP_ADDRESS: { type: "hash" },
      URL: { type: "redact" },
      PHONE_NUMBER: { type: "replace", value: "+1-555-0100" }
    }
  }
)
Enter fullscreen mode Exit fullscreen mode

That single call replaces the person with a synthetic name, masks the local
part of the email, hashes the IP, redacts the URL, and collapses any phone
number to a fixed placeholder. The five strategies compose per entity
because they live in the same overrides map — the call itself does not pick
a global mode.

FAQ

How does hash relate to GDPR?

Hash is a pseudonymisation technique — exactly the kind of risk-reduction
measure GDPR encourages for systems that hold personal data. A salted hash
gives you the best of both worlds: the log aggregator no longer holds the
original value (a meaningful improvement over plaintext), and the salt is a
separate secret you can govern independently of the data — rotate it, scope
it, audit access. If your goal is to correlate records across services
while keeping the original out of your logs, a salted hash is the right
tool. If your goal is to break the link to the original entirely
(irreversible anonymisation in the strict GDPR sense), that is a different
problem solved by deletion, aggregation, or noise injection, not by
hashing. The same framework also supports redaction, replacement, and
substitution when you want to remove the original outright.

Can I get the original back from substitute or replace?

No. substitute generates a synthetic value with no relationship to the
original that can be inverted; replace substitutes one fixed value you
supplied. Both are irreversible by design. If you need reversibility, the
anonymisation tool is the wrong choice — you want encryption, not
anonymisation, and you should think carefully about who holds the key and
under what lawful basis.

Is mask GDPR-compliant?

It depends on how much of the value you keep. Masking the first eight of
sixteen card digits still leaves eight digits, which is enough to
re-identify the cardholder against a known-card database. Mask is a
usability tool, not a privacy boundary. For a privacy boundary you want
redact, replace, substitute, or hash — in roughly that order of strength.

Does substitute preserve uniqueness?

No. The synthetic name Jane Doe may be produced for a hundred different
original subjects, and a substituted email may collide with a real email
elsewhere in the dataset. If you need a join key, use hash with a salt.
If you need realistic shape, use substitute. If you need both, apply them to
different entity types in the same overrides block.

Can I combine strategies per entity in one call?

Yes — that is the normal mode. The overrides map is keyed by entity type
(PERSON, EMAIL_ADDRESS, IP_ADDRESS, and so on), and each value is its
own strategy object. You can mix mask, redact, replace, hash, and
substitute freely across entity types in the same call. The one rule is
that one entity type gets one strategy per call; if you want to apply two
transformations to the same entity, you run the call twice or chain a
second one.

If you are mapping this to a real API

Use the same decision matrix, then swap in your provider’s exact entity names
and field names from the docs. The trade-offs stay the same even when the
syntax changes.

If you want a concrete implementation to compare against,
Veramask is the product behind the canonical examples on veramask.com.

Top comments (0)