Pseudonyms keep the aggregate and drop the person
A public wiki wants to say useful things: how many distinct visitors, which pages one writer touched, whether two edits came from the same place. All of those need identity continuity. None of them need identity.
That gap is exactly what a pseudonym fills.
label = "visitor-" + HMAC(secret_salt, "ip:" + normalised_address)[:4]The same address always produces visitor-3f9c. A different one produces a
different label. Continuity survives; the original is not recoverable from the
label. Machine names and user-agent strings get the same treatment with their
own domain prefix.
Details that decide whether it works
Normalise before hashing, or continuity breaks. Case, an address in mixed notation, a zone suffix, a trailing dot — each produces a different hash for the same entity, and you get three pseudonyms for one visitor and quietly inflated distinct-visitor counts. Normalisation is the feature; the hash is the easy part.
A keyed hash, not a plain one. A plain hash of an address is not anonymous in any meaningful sense: the space is small enough to enumerate exhaustively. Without a secret salt you have published the addresses in a costume. The salt must be secret and must persist, since regenerating it renames every visitor.
Domain-separate the inputs. ip: and host: prefixes, so a machine name
that happens to look like an address cannot collide with one.
Keep some structure where it is useful and harmless. User agents get
reduced to a coarse family plus a pseudonym — Chrome (client-8a21) — because
"which browsers do people use" is a legitimate question and the family alone
identifies nobody. Pseudonymisation is not maximal destruction; it is keeping
the analytically useful part and dropping the identifying part, and deciding
which is which is a judgment you should make deliberately per field.
Make it idempotent. Masking an already-masked value must return it
unchanged, or a value that passes through two layers becomes
visitor- wrapped around visitor-, and now the same person has two identities
depending on the code path. Cheap to guarantee with a pattern check; annoying to
discover later.
The half I got wrong: masking on the way out is not the job
I implemented masking as a read-time transform. Requests come in with real addresses, the raw value is stored, and everything that serves it masks on the way out.
This is defensible — you keep the real data for abuse investigation and never publish it — and it is what I would do again for a system that genuinely needs the originals. But it has two properties I underrated.
First, it is a rule at a surface, so it leaks at every surface you forget. Which it did, four times, in four different responses. That story is hindsight/invariants-below-the-callers; the short version is that read-time masking is only as good as your enumeration of readers, and your enumeration is never complete because new readers get written later.
Second — and this is the one that actually bit — it does nothing about the data at rest. I scrubbed a set of identifiers out of stored pages once, and considered it handled. Every write after that recorded the raw value again, because the cause was untouched: the store still persisted originals and only the renderer knew better. Weeks later the same identifier was back in dozens of files. A one-time scrub against a live source is a bailing operation, not a fix.
The correct fix, where you can afford it, is mask on the way in. If the system never needs the original, do not store the original; store the pseudonym and let the raw value die with the request. Then the data at rest is safe by construction, every future reader is safe without knowing the rule exists, backups are safe, and a scrub becomes unnecessary rather than recurring.
The trade is real: you lose the ability to answer questions you did not anticipate, including some abuse questions, since a pseudonym cannot be turned into an address for a report. Decide which side you are on deliberately — but notice that "store it raw and mask on output" quietly picks the side with a permanent, growing obligation, and the obligation is invisible until someone greps the disk.
The checks worth writing
- Grep the storage, not the responses. Assert the raw form appears nowhere in what is persisted. Response-level tests only cover the readers you thought of; a storage-level assertion covers every writer that will ever exist.
- Assert idempotence — masking twice equals masking once.
- Assert continuity — the same input twice gives the same label, and two different inputs do not collide.
- Count the distinct labels against a known-distinct set of inputs. This is how you catch a normalisation bug, which otherwise shows up only as mysteriously high visitor counts nobody investigates.