DEV Community

Veramask API Team
Veramask API Team

Posted on

How to anonymize data without breaking your analytics

Your privacy team ships anonymization. A week later, your analytics team
files a ticket: daily active users dropped 90%, the conversion funnel leaks
at every step, and duplicate detection stopped firing. Nobody did anything
wrong — anonymization did its privacy job, and in the same pass it destroyed
every join key in the dataset. The same person now looks like a different
person on every request, because each call generated a fresh fake value.

The fix is a pattern, not a product: make the anonymization deterministic
with a salt you keep
. Same input plus same salt equals same output — every
call, every service, every environment. The masked value becomes a stable
handle for the record it came from, so you can group, join, and trace exactly
as you did before, without storing any original value and without running a
tokenization vault. This article walks the pattern, the pseudocode, the salt
governance, and the cases where you should not use it.

Why anonymization breaks analytics

What you were doing What happens with random anonymization
GROUP BY user One real user fragments into N synthetic users — counts inflate, averages smear
Sessionization and funnels Steps can't be linked to one subject; funnels leak at every stage
Cross-service tracing Service A's log and service B's event carry different fake values for the same person
Duplicate detection Every copy of a record looks unique; dedupe never fires
Joining logs to a warehouse No shared key on either side
"Top users by activity" Rankings become noise; heavy users never concentrate on one token

The underlying issue: the anonymized value was carrying the identity.
Destroying identity is anonymization's privacy job — the mistake is
applying it where identity was also the analytical key. Determinism
restores the key while never emitting the original value. The token is a
handle to the record, not the record.

The pattern: salted determinism

No memory, no vault, no coordination service. You pick a secret salt, keep
it in your configuration, and pass it to the anonymization function. The
function mixes the salt into the transformation:

  • hash computes a salted SHA-256 of the value. Same input + same salt → the same 64-hex-character token, every time.
  • substitute picks the synthetic value as a deterministic function of the input and the salt. Same input + same salt → the same fake name, email, or phone number, every time.
// The whole pattern in one call:
anonymize(text, {
  salt: "0123456789abcdef", // >= 16 bytes, from your secret manager
  overrides: { EMAIL_ADDRESS: { type: "hash" } }
})
Enter fullscreen mode Exit fullscreen mode

The salt is the only cross-request link, and it is yours. Two services that
share nothing else — no shared database, no token service, no coordination
calls — derive the same token for the same value, because both run the same
function with the same salt. Correlation without a vault.

Hash tokens as join keys

The classic use: sanitize logs for PII while keeping them traceable.

anonymize("User Jane Doe (jane.doe@example.com) failed checkout from 203.0.113.42", {
  salt: "0123456789abcdef",
  overrides: {
    EMAIL_ADDRESS: { type: "hash" },
    IP_ADDRESS: { type: "hash" }
  }
})
// → "User Jane Doe (9f2c1a...) failed checkout from 8f223fc..."
Enter fullscreen mode Exit fullscreen mode

Every log line that mentions that email now carries the same 9f2c1a...
token. Grep for the token and you have the user's complete trail — without
the email existing in any line. Put the same salt in another service's
config and it computes the same token independently: cross-service joins
with zero infrastructure between them.

Stable synthetic values

Hashes are ugly in datasets humans read. Salted substitute gives you the
same determinism in realistic shape:

anonymize("Alice Smith can be reached at alice@example.com", {
  salt: "0123456789abcdef",
  overrides: {
    PERSON: { type: "substitute", attribute: "name" },
    EMAIL_ADDRESS: { type: "substitute", attribute: "email" }
  }
})
// → "Christopher Taylor can be reached at pespinoza@example.com"
Enter fullscreen mode Exit fullscreen mode

Every occurrence of Alice Smith in the salted corpus becomes
Christopher Taylor; every occurrence of her email becomes
pespinoza@example.com. A staging database seeded this way keeps working:
group-bys return stable per-user rows, and the data still looks like data.

One caveat: substitution does not preserve relationships between fields.
The fake name and the fake email are each individually stable, but they do
not describe the same fictional person. If you need referential integrity
across entity types, use the hash as the join key and substitute only for
human-readable surfaces.

Salt governance: the salt is a credential

Determinism moves the secret from a database into the salt. Treat it like
one:

  • Generate it randomly, store it in a secret manager — never in the repo, never in code.
  • Never log it next to the tokens it produces. A salt published beside its hashes turns every pseudonym into a verifiable guess target.
  • Scope it per purpose. One salt for log sanitization, a different one for the dataset you share externally. Tokens from one scope can't be correlated with the other, and one leak doesn't compromise everything.
  • Rotate it knowing the cost. A new salt re-baselines every token; new values stop matching historical ones. Version the generation (a token_epoch column) so consumers know which era a token belongs to, and destroy the old salt once the migration is done.
  • Deleting the salt is the off switch. With it gone, tokens can no longer be verified against candidate values — the pseudonymization link you created is severed, which is exactly what you want when the analytical purpose expires.

When not to salt

Determinism is a default for pipelines, not a law of nature:

  • Small populations. A stable token per subject preserves cardinality and frequency. In a five-user dataset, the token is the user — context alone re-identifies them. If a recurring fake value would itself be a leak, you want per-request randomness.
  • Low-cardinality fields. Fifty states map to fifty tokens; frequency analysis matches them in minutes. Deterministic substitution of location fields buys nothing analytically and confirms structure to an attacker.
  • One-shot jobs. A single log line that will never be joined to anything gains nothing from a salt — and every salt you manage is a credential with a rotation cost.
  • Freshness is the point. Demo screenshots, red-team fixtures, and load-test payloads often want different fake data every run. That is what unsalted anonymization gives you.

A token vault, minus the vault

The enterprise alternative is a tokenization vault: an encrypted store
mapping originals to tokens, with a detokenization API for authorized
callers. Vaults work — and they are a concentrated database of originals you
must run, secure, back up, and audit forever. The salt gets you the same
correlation property with a different architecture:

Tokenization vault Salted determinism
What it is A database of original ↔ token mappings A secret plus a pure function
Server-side store Yes — the vault holds every original None — nothing to store, nothing to breach
Reversible Yes, by design (detokenization) hash: not decryptable — with the salt you can verify a guess, never recover the value. substitute: not derivable at all
Operational cost Run, secure, back up, audit, scale the vault Keep one secret in the secret manager
Breach impact The vault is a single point of total PII compromise A leaked salt is a rotated credential; no data store sits behind it
GDPR posture Pseudonymisation with added attack surface Pseudonymisation with subtractive storage

If you need the original back later — support workflows that must
re-identify a customer — you want a vault or encryption, not anonymization.
If you need correlation without recovery, the salt is the whole answer.

FAQ

Is a salted hash anonymous data?

No — it is pseudonymisation. Because the salt plus the token lets you verify
candidate values, GDPR treats the output as personal data; the regulation
recognizes the technique, under Article 32(1)(a), as a security measure
rather than an exemption. If your goal is true anonymization — data that
exits GDPR scope — the tools are deletion, aggregation, and noise, not
hashing.

What happens when I rotate the salt?

Every token changes. Treat rotation as a re-baseline: version the token
generation so both eras stay queryable, or accept the cliff and reprocess
what you can. Keep the old salt only for the migration window, then destroy
it — a retired salt has no analytical value and nonzero risk.

Can I use one salt for everything?

Technically yes; hygienically, scope per purpose. One salt means one leak
compromises every dataset's linkability, and internal-log tokens could be
correlated with tokens in a dataset you shared externally. Separate salts
per purpose are free — more entries in the secret manager — and they cap the
blast radius of any disclosure.

Why do I get different outputs for the same input?

Usual causes: no salt passed at all (unsalted substitutions and hashes vary
by request — that is normal behavior); a different salt between calls; or
the two calls ran with different strategy overrides. Confirm the exact same
salt string and the same overrides are being sent every time before
debugging anything else.


If you want a concrete implementation of this pattern to compare against,
Veramask — the PII anonymization API behind the canonical examples on
veramask.com — implements it as the consistency_salt
setting on its hash and substitute strategies, with the stateless
contract that keeps the salt and the payloads out of any server-side store.

Top comments (0)