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" } }
})
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..."
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"
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_epochcolumn) 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)