A companion to the AI gateway series. The context is in Your AI usage policy doesn't prevent the leak.
Summary
- PII is any data that identifies a person; under Brazil's LGPD, personal data is that plus anything that identifies when combined. LLM prompts carry both, all the time.
- There are four layers of protection — anonymization, pseudonymization, contract and local model — and they don't compete: they stack, per route.
- Redacting at the boundary is the standard recommendation, and nobody publishes a hit rate. I measured Presidio out of the box on eleven Brazilian cases: it detects 6, and misses CPF, CNPJ and license plates.
- Worse than missing: it classifies a credit card as
LOCATIONand the RG id asPERSON. Wrong type means wrong masking rule. - Three recognizers with check-digit validation fix it for +0.16 ms per call. They're in the open repository.
1. What counts as PII — and what the LGPD calls personal data
PII (personally identifiable information) is any information that identifies a person: name, national ID, email, phone number, address.
Brazil's LGPD uses a wider concept. Personal data (art. 5, I) is information relating to an identified or identifiable person — which includes combinations: a postal code plus a birth date plus a job title identifies someone even with no name in the text. And there's the aggravated category of sensitive data (health, biometrics, racial origin, religious belief), with stricter rules.
For anyone operating systems with AI, the practical consequence is a single one: a prompt is personal-data processing whenever it carries customer data. Support tickets, call transcripts, registration records, contracts — all of it becomes a prompt in some automation, and from that point on the company answers for what left.
2. Why sending a prompt to an LLM is a personal-data problem
Three properties of a model call make this different from sending data to any other SaaS:
- The prompt is the entire payload. It isn't a structured field you choose to send; it's free text, and free text carries whatever is in the context — including what nobody meant to send.
- Retention and training depend on the contract. What the provider keeps, for how long, and whether it trains on it varies by tier — free plans usually have different rules from paid ones. Data that enters a training corpus doesn't come back.
- The call leaves from inside the code. It isn't an employee deciding to paste something into a website: it's agents, scripts and automations doing it thousands of times a day, with no human review. The three routes are mapped in Your AI usage policy doesn't prevent the leak.
A written policy doesn't intercept requests. A technical layer does — and that's what the rest of this post is about.
3. The four layers of PII protection in AI calls
In increasing order of cost and of guarantee. They don't compete — they stack, and the right combination varies per route.
Anonymization at the boundary. A layer detects and masks the identifier before the call leaves: CPF 529.982.247-25 becomes CPF <BR_CPF>. Irreversible. It's the protection this post measures, and the only one of the four whose effectiveness can be expressed as a hit rate.
Pseudonymization. Same detection, but the substitution is reversible: the <PERSON_1> → João map stays on your side, and the model's answer is re-hydrated on the way back. Necessary when the answer needs to reference the person. Note: under the LGPD, pseudonymized data is still personal data — reversibility keeps the obligation.
A contract with the provider. Zero retention, no-training clause, processing region. Reduces risk in a real way and rests on contractual trust: the data leaves, you've only agreed on what happens to it.
A local model. The data doesn't leave. The only layer that turns a contractual guarantee into a technical one, and it costs quality, hardware and operations — when that trade is worth it is in The question isn't whether a small model is good enough.
4. Anonymization in practice: how Microsoft Presidio works
Microsoft Presidio is the most common open-source tool for this: it runs on your infrastructure, sends nothing anywhere, and fits into the call path.
It has two parts. The Analyzer finds PII in text by combining two mechanisms — an NLP engine (spaCy, by default) that recognizes entities like names and organizations, and pattern recognizers (regex plus validation) for identifiers with a format, like cards and phone numbers. The Anonymizer applies the mask over what the Analyzer found: replace, redact, hash or encrypt, per entity type.
That architecture matters for what comes next: Presidio's coverage is the sum of what the NER engine knows and which recognizers are registered. And the recognizers that ship out of the box were written for documents from the US, UK, Spain and the like. Brazil is not on the list.
"Anonymize before the call" is the recommendation every compliance material makes — with no number attached. Nobody writes down how much the tool catches, what it misses, or what it costs in latency. So I measured.
5. What Presidio detects out of the box on Brazilian PII
Eleven cases, built with the formats that show up in support, contracts and registration in Brazil. spaCy engine pt_core_news_sm, Portuguese language.
| case | detected | classified as |
|---|---|---|
formatted CPF 529.982.247-25 |
no | — |
bare CPF 52998224725 |
no | — |
formatted CNPJ 11.222.333/0001-81 |
no | — |
bare CNPJ 11222333000181 |
no | — |
Mercosul plate BRA2E19 |
no | — |
phone (54) 99251-3223 |
yes | PHONE_NUMBER |
| yes | EMAIL_ADDRESS, ORGANIZATION, URL |
|
postal code 95020-000 |
yes | LOCATION |
RG 12.345.678-9 |
yes | PERSON |
card 4111 1111 1111 1111 |
yes | LOCATION |
| a person's name | yes | PERSON |
Six of eleven. And the five it misses are precisely the national identifiers.
The problem isn't only what it misses
Look at the two bold rows.
A credit card classified as LOCATION. If your policy masks CREDIT_CARD one way and LOCATION another — or doesn't mask locations at all, which is common — the card number leaves intact.
The RG id classified as PERSON. Same thing: the rule you wrote for a person's name gets applied to a document number.
Detection with the wrong type is worse than no detection, because it doesn't show up in any report as a failure. The detected-PII counter goes up, the data leaves anyway, and nobody goes looking.
6. How to detect CPF and CNPJ: the missing recognizers
CPF and CNPJ have check digits. That matters: a recognizer that only matches "eleven digits" flags order numbers, tracking codes and internal identifiers. With check-digit validation, it only flags the real thing.
class CPFRecognizer(PatternRecognizer):
PATTERNS = [
Pattern("masked CPF", r"\b\d{3}\.\d{3}\.\d{3}-\d{2}\b", 0.6),
Pattern("bare CPF", r"\b\d{11}\b", 0.3),
]
CONTEXT = ["cpf", "documento", "contribuinte", "titular"]
def validate_result(self, text):
n = "".join(c for c in text if c.isdigit())
return _dv_cpf(n) if len(n) == 11 else False
Two design details are worth explaining.
The bare pattern's confidence is lower — 0.3 against 0.6. Eleven loose digits are ambiguous; with the mask, almost never. CONTEXT recovers the difference: if the word "CPF" appears nearby, confidence goes up.
validate_result is what prevents the false positive. Without it, every eleven-digit number becomes PII, and then the team turns the whole redaction layer off because it hurts more than it helps.
Same for CNPJ, and a third one for plates in both formats — Mercosul and the old one.
What changes afterwards
| case | before | after |
|---|---|---|
| formatted CPF | no | BR_CPF |
| bare CPF | no | BR_CPF |
| formatted CNPJ | no | BR_CNPJ |
| bare CNPJ | no | BR_CNPJ |
| Mercosul plate | no | BR_PLACA |
111.111.111-11 (invalid) |
no | no — check digit rejects |
52998224726 (not a CPF) |
no | no — check digit rejects |
The last two rows are the point. Detecting a CPF is easy; detecting a CPF without flagging every eleven-digit number is what makes a team keep the redaction layer on.
7. What anonymization costs: measured latency
This is the part that decides where redaction enters the path.
| measurement | latency |
|---|---|
| short text (~55 characters), Presidio out of the box | 4.48 ms |
| short text, with the three recognizers | 4.64 ms |
| long text (~3,240 characters), Presidio out of the box | 89.6 ms |
The three recognizers cost 0.16 ms — the difference between the first two rows. They're regex plus an arithmetic validation; the cost is irrelevant next to the rest, which is why the long text was only measured out of the box.
What costs is Presidio itself, and it scales with text size: 4.5 ms on a short snippet, almost 90 ms on a three-thousand-character text. That's natural-language analysis, not pattern matching.
And there lies the practical implication. In the gateway overhead post I measured about 10 ms for the layer. Adding redaction on a long prompt can add almost ten times that, and it becomes the most expensive item on the path before the model.
Two consequences:
- Anonymize the route, not everything. Turn it on where customer data flows; leave it off elsewhere. In the gateway that's one line per alias.
- Measure at your prompt size. 90 ms came from three thousand characters. If your prompt has twenty thousand, the number is different.
Measurement conditions: Linux development machine, spaCy pt_core_news_sm, 200 runs for the short text and 50 for the long one, single round. Order of magnitude, not a citable benchmark.
8. What else plugs into Presidio
The three recognizers close CPF, CNPJ and plates. Two fronts remain: the documents I didn't cover, and the NER engine that classifies a card as LOCATION. Both are solved without switching tools.
More documents, without writing the arithmetic: validate-docbr. A Python package that validates CNH, CNS, PIS, voter ID and RENAVAM, plus CPF and CNPJ — all with check digits. The fit is the same as section 6: one PatternRecognizer per document, with validate_result delegating to the package instead of the manual math.
A bigger engine in the same place: pt_core_news_lg. The measurement ran on pt_core_news_sm, spaCy's smallest Portuguese model. The large one carries 500k word vectors and tends to miss fewer names and organizations. The swap is one configuration line — and it's worth running the same eleven cases before and after, because a bigger model is no guarantee on your text.
Swapping the engine for a transformer: TransformersNlpEngine. Presidio accepts any Hugging Face token-classification model as its NER engine; the configuration just maps the model's labels to Presidio's types. In Portuguese there's ner-bert-base-cased-pt-lenerbr, a BERTimbau fine-tuned for NER with an F1 of 0.89 — on LeNER-Br, which is legal text. Two caveats: outside the legal domain that number drops, and a transformer costs latency exactly in the part that already scales with text size (section 7).
The decision rule: identifiers with a format are solved with regex and check digits — cheap and deterministic. A bigger NER engine, only if names and organizations are leaking. A transformer, only with section 7's numbers re-measured, because it changes the order of magnitude of the cost.
9. Where redaction enters the architecture
In the gateway, as a guardrail in pre_call mode, attached per virtual key. The application doesn't change; the route that needs redaction gets a key that carries it, and the others don't. The full design of the layer is in An AI gateway in 40 lines of YAML.
And the honest limit: anonymization removes identifiers. It works for personal data — national IDs, emails, phone numbers, names. It does not solve trade secrets: there's no way to anonymize the logic of a proprietary algorithm without destroying the question you're asking the model. If that's the problem, the answer is a local model, not redaction.
The three recognizers are in redaction/ in the open repository, alongside the diagnostic script and the gateway stack.
Before turning it on anywhere, run it on your data. Eleven constructed cases measure format coverage, not recall on real production text — and your domain has formats mine doesn't.
Frequently asked questions about PII and LLMs
Does anonymizing the prompt satisfy the LGPD? Irreversibly anonymized data is no longer considered personal data (LGPD art. 12). Masking at the boundary reduces exposure in a measurable way; if the substitution is reversible (pseudonymization), the data remains personal and the obligations remain. This is engineering, not legal advice — the lawful basis for processing is still your legal team's question.
Can I send a national ID to an AI model? Technically, nothing stops you. Legally, it's personal-data processing: it requires a lawful basis, and the model provider joins the chain as a processor. In practice, the safe options are masking before it leaves or processing on a local model.
What's the difference between anonymization and pseudonymization?
Anonymization is irreversible: <BR_CPF> never becomes the CPF again. Pseudonymization swaps in a reversible marker with the map kept on your side — useful when the answer needs to reference the person, but it keeps the data personal under the LGPD. Presidio's Anonymizer does both, per entity type.
Does Presidio work in Portuguese? It does, with a Portuguese spaCy engine — but the Brazilian document recognizers don't ship out of the box. Without them, CPF, CNPJ and plates pass straight through, as the measurement above shows. With the three recognizers registered, coverage closes for +0.16 ms per call.
How we help teams adopt AI
We work with engineering teams putting AI into the real development workflow — not as an experiment, but as an actual part of how the team ships.
That means choosing the right tools for the team's context, configuring them in a way that makes sense for the company's data policy, and making sure developers know how to use them in a way that genuinely increases productivity instead of adding friction.
If you have a development team and you're trying to put AI to work seriously, get in touch.
