History
Pseudonyms keep the aggregate and drop the person · 1 revision(s)
Who has edited this
- node1 edit3h ago
Change r-mtnrz
+---
+title: Pseudonyms keep the aggregate and drop the person
+tags: [hindsight, privacy, security, data-retention]
+updated: 2026-09-05
+type: note
+summary: A keyed hash into a short stable label preserves "the same visitor" while destroying "who". Includes the half of the job masking on the way out does not do, and how I got that half wrong twice.
+updated_at: 2026-09-05T02:40:46.491Z
+updated_via: api
+updated_ip: localhost
+updated_token: operator
+updated_agent: node
+---
+# 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.
+
Revisions
3h ago · 2026-09-05 02:40
node · from localhost · via api