Skip to content

9 min read

Masking at read time controls who looks, not what you hold

On this page

A payments company shipped a trace viewer with field-level masking in about three weeks. Sensitive attributes were stored as they arrived and rendered as •••••••• unless the viewer's session carried the trace:reveal scope, which twelve on-call engineers had and nobody else did. Reveals were logged. The security review passed on the first pass, and the design was written up internally as the responsible alternative to destroying data at ingest.

Four months later a data scientist wanted to plot latency against prompt length and did what data scientists do, which was to skip the UI:

bash
curl -s "https://traces.internal/api/v1/spans?trace_id=1a77f0c9&format=json" \
  -H "Authorization: Bearer $TOKEN" | jq '.spans[0].attributes["gen_ai.prompt"]'
json
"Hi, I need to dispute the charge on card ending 4417 for Marcus Bell,
 DOB 1984-11-02, 14 Chandler Row — the merchant says the refund went to
 the wrong account, sortcode 20-45-11 acct 61004392"

The masking lived in a React component that rendered the attribute panel. The API underneath it returned attributes verbatim to anyone holding a read token, which at that point was every service account in the data platform and a couple of hundred employees through SSO. This is the ordinary failure mode of read-time masking rather than an unusual one, because masking is a property of a rendering path and production systems accumulate rendering paths — the API, the CSV export, the alert that quotes the offending span into Slack, the nightly extract that feeds the analytics warehouse, the vendor's own search index.

When the payments company went looking for the other read paths, they found eleven. The UI, the JSON API, a CSV export built for a customer success workflow, the alerting integration that quoted matching spans into Slack, a nightly extract into the analytics warehouse, the vendor's own full-text search index, a Grafana datasource pointed at the same backend, two internal notebooks with service tokens, a support tool that rendered a conversation for the agent, and the vendor's support engineers themselves. The masking component covered one of the eleven. That ratio is not a comment on the team's diligence, since ten of those paths were built after the masking was, by people who had been told the trace store was safe to read from.

The interesting part is what would have been true even if the masking had been perfect and complete on every path. The unredacted text was in the store. That single fact settles several questions at once, and none of them are questions about access.

Three controls, and the different things each one governs

Design reviews for this problem tend to produce the same three proposals, and they get compared as if they sat on a single axis from "weak" to "strong". They do not; each one intervenes at a different point and leaves a different set of obligations untouched.

A: redact at ingestB: store all, mask at readC: hash all user content
What it changeswhat exists in the storewho can read what existsthe form the content is stored in
Retention windowthe sensitive content was never retainedthe raw copy ages on the full 30-day clockhashed copy retained for the full window
Residency commitmentonly structural fields cross regionsthe raw copy is wherever the store ishashes are still personal data, still located
Deletion requestlittle or nothing to findevery raw span must be found and deletedevery hash must be found and deleted
Insider risklow: the text is gonedepends entirely on every read pathlow for content, high for join-based inference
Breach exposurestructural fields onlyfull customer contentrecoverable for low-entropy inputs
Debuggabilitystructure survives, text does notcompleteclose to zero
Failure modeover-redaction of useful contentone unmasked path defeats the whole designirreversible loss with no privacy gain

Option B is the one that gets chosen, and the reason it is tempting is honest enough: it is the only one of the three that does not trade anything away at the moment you build it. Everything is still there, the people who need it can see it, and the control is a matter of configuration rather than of deciding in advance what you will never be able to look at. What it buys is a reduction in casual access by employees, which is a real risk and worth reducing. What it does not touch is any obligation that attaches to holding data rather than to reading it.

Concretely, when a subject access request or an erasure request arrives — an individual asking to see, or to have destroyed, everything you hold about them — B has to locate and remove customer content from every span in a 30-day window, in a system that was probably not built with per-subject deletion in mind. When an enterprise contract commits to EU-only processing — a data residency commitment, which is a promise that the content and everything derived from it stays inside a named geography — B places unredacted customer content in whatever region the trace vendor's cluster sits in, and the masking layer has no opinion about that. When the trace store is breached, B loses everything the customer typed. A retention clock, a residency line and a deletion workflow all key off what is stored, so a control that changes only who reads leaves all three exactly where they were.

Hashing keeps the join and therefore keeps the risk

Option C has a cleaner story and a more specific defect. Hashing user content — running SHA-256 over the free-text field, or over the email address, and storing the digest — is often presented as anonymisation, on the reasoning that a hash cannot be inverted. That reasoning is about the function and not about the data, and it fails in two separate ways.

The first is brute force over small input spaces. Hashes are not invertible in general and are entirely reversible when the set of possible inputs is enumerable. A UK national insurance number has on the order of 10^9 valid forms; a date of birth has about 3 × 10^4; a mobile number about 10^9. A single consumer GPU computes SHA-256 at roughly 10^10 hashes per second, so exhausting any of those spaces takes under a second, and the "hashed" column is a plaintext column with an extra step. Salting per record fixes the precomputed-table attack and does nothing about the enumeration, because the salt is stored beside the hash. An HMAC — a hash keyed with a secret, so that nobody without the key can compute the digest for a candidate input — with that key held in the KMS and never written next to the data is a genuine improvement, because it makes recovery contingent on a key compromise rather than on arithmetic. It is still pseudonymisation, meaning the identifier has been replaced by a stable substitute while the means of reversing the substitution continues to exist somewhere, rather than anonymisation, in the sense the General Data Protection Regulation uses those words. The GDPR is the EU law governing personal data and the source of most of the obligations discussed in this topic, and its Article 4(5) describes exactly this arrangement — additional information held separately — and keeps the data in scope.

The second failure is the one that matters more, because it survives any amount of cryptographic care. The reason to hash rather than to drop is to keep traces joinable, and the join key is the re-identification vector. If sha256(email) appears on a trace, on a support ticket export, and on a marketing list, then anyone holding two of those three can attach a name to trace activity without ever inverting anything. This is not hypothetical: AOL's 2006 release of 20 million search queries replaced usernames with numbers, and journalists identified user 4417749 as a specific person in Georgia within days by reading the queries themselves. Two years later Narayanan and Shmatikov de-anonymised Netflix Prize users by joining a pseudonymous ratings set against public IMDb reviews, needing only a handful of overlapping data points per user. Both datasets had removed every direct identifier. The pattern in both is that the content and the linkage do the identifying, and stripping names is orthogonal to that.

Hashing free text specifically has a third problem that is merely practical: it destroys the content without buying anything, since a digest of a 268-character complaint is neither readable by an engineer nor safe to join, and it is unequal to itself the moment a single character differs. The workload where hashing is genuinely correct is narrow — a stable identifier you need for grouping and never need to read.

python
import hmac, hashlib

def subject_ref(value: str, tenant_id: str, key: bytes) -> str:
    """Stable, tenant-scoped pseudonym. Key lives in KMS, never in the store."""
    msg = f"{tenant_id}\x00{value.strip().lower()}".encode()
    return "u_" + hmac.new(key, msg, hashlib.sha256).hexdigest()[:12]

It is worth being precise about what would count as anonymisation, because the word gets used loosely enough that people believe it is a transformation they can apply to a record. Recital 26 of the GDPR sets the bar at data that can no longer be attributed to an identifiable person by any means reasonably likely to be used, which no per-record transformation achieves while the record retains one row per event. Reaching it requires losing information at the population level — aggregating to counts over groups large enough that no individual is distinguishable, or generating synthetic data with formal privacy guarantees — and the result is not a trace any more, it is a statistic. A dataset that supports the question "what did user X ask on Tuesday" is by construction not anonymous, and a trace store exists to support exactly that question.

Two properties of the pseudonym function are load-bearing. Scoping to the tenant means the same email address under two customers produces two different refs, which stops cross-customer joins inside your own store. Rotating the key breaks the join deliberately, which is the mechanism you use when a subject's data must stop being linkable — the pseudonym becomes an orphan string that resolves to nothing.

What to build on, and what B is actually for

The design that holds is A from the previous chapter as the base, with masking used where it belongs and hashing confined to the identifier role. Class-driven redaction determines what enters the durable store. Read-time masking is the right control for the 72-hour raw window, because that window contains real text by design and the entire question there is who may look at it and under what recorded circumstances. Pseudonyms carry the joins that redaction would otherwise break, keyed and tenant-scoped so the join works inside your boundary and nowhere else.

Read in that order, B stops being a competitor to A and becomes the second gate behind it. The mistake is using it as the only gate, and the reason the mistake is durable is that B's weaknesses are invisible during the review that approves it. Nobody demonstrates a retention violation in a design meeting; it shows up eight months later, in a deletion request or a residency questionnaire, as a fact about data that was written long ago.

One more asymmetry is worth naming plainly. A is destructive and its errors are recoverable in the sense that you find out immediately — an engineer opens a trace, the field they needed is gone, and they file a ticket to change the class. B is non-destructive and its errors are discovered only when someone reads data they should not have, or when a regulator asks a question you cannot answer. A control whose failures are loud on the day you make them is easier to operate than one whose failures accumulate silently, independent of which one is theoretically stronger.

The limits of anything you do to your own store

All three options are decisions about a store you run. None of them touches the copy that the model provider received, which was neither redacted nor masked nor hashed, because the model needs the actual text in order to answer. The prompt in the opening example went to an inference endpoint in full, along with the card number and the sort code, and whatever that provider does with it is governed by a different mechanism entirely — a retention setting, a contract, and a set of processing behaviours you did not write.

That mechanism has a checkbox, and the checkbox has a scope that is narrower than the sentence people write about it.

Data retention and privacy in logs — read the current version at https://daviddominguez.dev/learn/en/data-retention-and-privacy-in-logs/masking-at-read-time-controls-who-looks-not-what-you-hold

Back to top