Back to writing

Mask before you call the LLM

Swapping an ID for a placeholder is the start. The hard parts are the reverse map, the detector, the quasi-identifiers and the answer that comes back with the wrong placeholder.

When a healthcare or financial system calls an external language model, every prompt can carry a name, a tax ID, a diagnosis or amounts. If that leaves your infrastructure untreated, the problem stops being technical and becomes a legal and trust risk.

The idea of a privacy layer is simple: before the call, detect the sensitive entities, swap each one for a placeholder and keep the reverse map inside your perimeter. The model reasons over placeholders, and the response is rehydrated on your side only.

input: Maria Souza, CPF 123.456.789-09, admitted on 12/03
model: [PERSON_1], CPF [CPF_1], admitted on [DATE_1]

So far, everyone agrees. The trouble is that the naive version protects far less than it seems, and the holes are in four places.

A stable token is pseudonymization, not anonymization

The temptation is to make the same ID always become the same placeholder, across every call, so the model “remembers” the person. That creates linkability: anyone who sees the requests can gather everything that passed under the same placeholder and build a profile without ever seeing the ID. And the reverse map becomes the most sensitive asset in the system, because it's the only thing standing between a pile of placeholders and a pile of patients.

Under Brazil's LGPD, as under the GDPR, pseudonymized data is still personal data. It only stops being personal when the anonymization can't be reversed, and a reverse map exists precisely to reverse it. So the placeholder doesn't take you out of the law's scope; it only reduces what leaks if the call is intercepted.

Swapping the map for a hash doesn't help either. The CPF, Brazil's tax ID, has around 10⁹ possible values, and computing the SHA-256 of all of them fits in an afternoon on a laptop. An unkeyed hash of an identifier with a small space is just the ID written another way.

The minimum I consider reasonable:

  • Scope per conversation or per tenant, never global. Within one conversation, sequential placeholders (CPF_1, CPF_2) are enough.
  • When you need consistency across calls, as with a document processed in chunks, use an HMAC with a key per scope, and rotate the key.
  • The map treated as a vault: encrypted, with restricted access, short retention and its own access trail.
token = "CPF_" + base32(HMAC(key[tenant][conversation], cpf))[:8]

Sending PII to detect PII

A common piece of advice is to start with deterministic rules, regexes and validators for documents, and leave an LLM for the cases the rules can't reach. The advice is right, with a condition that tends to get left out: that model has to run inside the perimeter. If the detector is an external API, you send the raw text out precisely to find out what you weren't allowed to send out.

In practice, detection stays entirely on your side: regexes and validators (CPF check digits, health card numbers, phone, email, dates), lists of known entities, an NER such as Presidio's with a Portuguese model and, if needed, a small model running locally. The external model only sees text that has already been through all of that.

text
  -> regexes + validators (CPF, CNS, phone, email, dates)
  -> NER / local model, inside the perimeter
  -> generalization of quasi-identifiers
  -> scoped placeholders + map in the vault
  -> external LLM
  -> validation of the placeholders in the answer
  -> rehydration

In healthcare, the leak lives in the quasi-identifiers

Name and ID are the easy part. What really re-identifies a patient is the combination of attributes that look harmless on their own. Latanya Sweeney showed in 2000 that ZIP code, date of birth and sex were enough to uniquely identify about 87% of the US population. In free-text medical records it's worse, because those attributes come mixed in with everything else.

No regex catches “34-year-old patient with HIV seen in Sorocaba”. There's no name and no document, and in a regional health insurer's database it may be exactly one person. This is where the real problem lives.

Replacing doesn't solve it, because the model needs the clinical content: in a case summary, the diagnosis is the subject. The right tool is generalization, deciding per task what the model really needs to know:

AttributeOriginalGeneralized
Age34 years old30 to 39
Dateadmitted on 12/03[DATE_1], or “D+3” relative to admission
CitySorocabainland São Paulo state
DiagnosisHIVkept, if the task needs it

And you can measure it. If, after generalizing, only one patient in the database matches “30 to 39, inland São Paulo, HIV, admitted in March”, the generalization wasn't enough. It's the intuition behind k-anonymity applied to what's left in the text: every combination that reaches the model should match several people, not one.

Rehydration fails too

The way back is where nobody looks. The model may change the placeholder's format ([CPF_1] becomes CPF 1, [cpf_1] or CPF_01), invent one that doesn't exist ([PERSON_3] when there were only two people) or swap placeholders around. A naive find-and-replace leaves a broken placeholder on screen or, worse, puts the wrong patient's name in the answer.

  • Placeholders with a rigid format that's hard to paraphrase, and a recognizer that finds the variations the model tends to produce.
  • Every placeholder in the answer must exist in that conversation's map. An unknown placeholder means rejecting or regenerating, never guessing.
  • A placeholder from another conversation or tenant never resolves, even if it exists in the vault.
  • After rehydrating, no placeholder may be left in the text.
for token in find_tokens(answer):
    if token not in conversation_map:
        raise UnknownToken(token)

Measure what slips through

Masking nobody tests is just a feeling of safety. Keep a set of synthetic cases that look like the real ones, including quasi-identifiers in free text, measure the detector's recall per entity type and count what reaches the external model. The metric that matters is what leaked, not what was caught.

This goes hand in hand with The prompt is not a security boundary: one layer handles what the model sees, the other handles what it can do.