PII Redaction for LLM Applications
LLM PII redaction is the practice of detecting and removing personally identifiable information (names, emails, phone numbers, card numbers, medical IDs) from text before it reaches a language model, and often restoring it in the response afterward. You do it because prompts and completions get logged, cached, sent to third-party APIs, and sometimes used for training, so any raw personal data in that flow becomes a breach waiting to happen. This guide shows where redaction belongs in a real pipeline, which detection methods catch what, and how to build a redact-then-restore flow you can run today.
Why LLM PII redaction is different from classic scrubbing
Traditional PII scrubbing was built for structured systems: a database column labeled email, a form field named ssn, a log line with a known format. You knew where the sensitive data lived, so you masked that field and moved on.
LLM applications break that assumption. Personal data arrives inside free-form text: a support ticket, a chat message, a pasted contract, a voice transcript. There is no column name to key off. The same paragraph can hold a customer name, an order number, a home address, and a throwaway complaint, all mixed into ordinary prose. LLM PII redaction has to find the sensitive spans inside unstructured language, which is exactly the hard part.
Three properties make the LLM case its own problem:
- The data surface is huge. Every prompt is attacker-controlled or user-controlled text. Anything can show up anywhere.
- The data moves outward. Unless you self-host, prompts leave your trust boundary and hit a vendor API. Retention and training policies vary by provider and by plan.
- The data gets duplicated. Prompt caching, request logging, evaluation datasets, and vector stores all make copies. One un-redacted prompt can seed five systems.
So LLM PII redaction is not a single mask step. It is a discipline applied at several points in the request lifecycle. Get the placement right and the detection method is a detail. Get the placement wrong and the best detector in the world only cleans one leak while four others stay open.
What counts as PII
Before you redact anything, agree on scope. Over-redacting destroys the model's ability to answer; under-redacting leaves you exposed. A practical tiering for most products:
- Direct identifiers: full name, email, phone, street address, government IDs (SSN, passport, national ID, tax ID), payment card numbers, bank account and IBAN, driver's license.
- Quasi-identifiers: date of birth, ZIP or postal code, employer, job title, IP address, device IDs, precise geolocation. Individually weak, but combine two or three and you can re-identify a person.
- Sensitive categories: health conditions, diagnoses, medications, biometric data, sexual orientation, religion, union membership, criminal history. Regulations like GDPR and HIPAA treat these as special categories with stricter handling.
- Secrets that ride along: API keys, passwords, session tokens, private keys. Not PII in the legal sense, but you want them out of prompt logs just as badly.
Write this list down as a config, not as tribal knowledge. Your detector, your tests, and your compliance reviewer all need the same definition.
Where redaction belongs in the request lifecycle
Map your data flow first, then place a redaction control at each boundary the data crosses. A typical LLM feature has four:
- Ingress: user text enters your system.
- Egress to the model provider: the prompt leaves your servers.
- Logging and telemetry: prompts and completions get written to observability tools.
- Storage: transcripts, evaluation sets, and vector embeddings persist.
The single highest-value control is redacting before the prompt leaves your trust boundary, that is, between steps 1 and 2. If you strip PII there, the provider never sees it, your logs never store it, and your vector database never embeds it. One well-placed detector covers three downstream leaks.
But do not treat egress as the only line. Redact your logs independently. Teams route completions to observability platforms and forget that the model's own output can contain PII it inferred or echoed back. If your logging pipeline writes raw request and response bodies, add a redaction pass in the logging middleware itself, so a misconfigured detector upstream does not silently poison your log store.
A concrete rule: no raw user text should ever be written to durable storage or sent to a third party without passing a redaction function. Enforce it in code review, not in a wiki page.
The redact-then-restore pattern
For many features you cannot just delete PII, because the model needs it to do its job. If a user writes "reschedule Dr. Aisha Rahman's appointment to next Tuesday," a model that never sees the name cannot draft a useful reply. The answer is redact-then-restore, sometimes called tokenization or reversible masking.
The flow:
- Detect PII spans in the incoming text.
- Replace each span with a stable placeholder token, for example
[PERSON_1],[EMAIL_1],[PHONE_1]. - Keep a mapping from placeholder to original value in memory, never sent to the model.
- Send the redacted prompt to the LLM.
- When the completion comes back, swap the placeholders back to the real values before showing the user.
The model reasons over structure ("reschedule [PERSON_1]'s appointment") without ever receiving the raw identity. The user sees a natural response. Your logs and the provider's servers only ever hold placeholders.
Here is a compact Python implementation using Microsoft Presidio, an open-source PII detection library that wraps spaCy NER plus regex and checksum recognizers:
from presidio_analyzer import AnalyzerEngine
from presidio_anonymizer import AnonymizerEngine
from presidio_anonymizer.entities import OperatorConfig
analyzer = AnalyzerEngine()
anonymizer = AnonymizerEngine()
def redact(text, language="en"):
results = analyzer.analyze(text=text, language=language)
# Build a reversible map: placeholder -> original span
mapping = {}
counters = {}
operators = {}
# Sort by position so counters are stable left to right
for r in sorted(results, key=lambda x: x.start):
etype = r.entity_type
counters[etype] = counters.get(etype, 0) + 1
token = f"[{etype}_{counters[etype]}]"
original = text[r.start:r.end]
mapping[token] = original
# Presidio replaces per entity type; we override with our tokens
def make_operator(etype):
state = {"n": 0}
def replace(entity):
state["n"] += 1
return f"[{etype}_{state['n']}]"
return replace
anonymized = anonymizer.anonymize(
text=text,
analyzer_results=results,
operators={
r.entity_type: OperatorConfig(
"replace",
{"new_value": f"[{r.entity_type}_PLACEHOLDER]"},
)
for r in results
},
)
return anonymized.text, mapping
def restore(text, mapping):
for token, original in mapping.items():
text = text.replace(token, original)
return textThe Presidio operator API is finicky about stable numbered tokens, so in production most teams build the placeholder mapping from analyzer.analyze results directly and do the string substitution themselves rather than leaning on the anonymizer for numbering. The key ideas are the same: detect spans, replace with stable tokens, hold the mapping outside the prompt, reverse on the way out.
A cleaner hand-rolled version that gives you full control:
def redact_manual(text):
results = sorted(analyzer.analyze(text=text, language="en"),
key=lambda x: x.start, reverse=True)
mapping = {}
counters = {}
for r in results:
etype = r.entity_type
counters[etype] = counters.get(etype, 0) + 1
token = f"[{etype}_{counters[etype]}]"
mapping[token] = text[r.start:r.end]
text = text[:r.start] + token + text[r.end:]
return text, mappingIterating in reverse position order matters: if you replace spans left to right, every substitution shifts the indices of the spans after it and your offsets go stale. Replacing from the end backward keeps earlier offsets valid.
Restore safely, without prompt injection
Restore looks trivial: string-replace each placeholder back. Two traps.
First, the model can invent placeholders. If you send [PERSON_1] and the model, being helpful, writes [PERSON_2] for someone it made up, your restore map has no entry and the fake token leaks to the user. Guard against it: after restore, scan the output for any leftover [A-Z_]+_\d+ pattern and either strip it or fail the response. Never ship text with orphan placeholders.
Second, the model output itself is untrusted when you feed it back into anything with side effects. Restoring real PII into a string that then gets rendered as HTML, run as a shell command, or used as a SQL parameter reintroduces injection risk. Restore is a display-layer operation. Do it as late as possible, after all machine processing, right before the human sees the text.
Detection methods and their trade-offs
No single detector catches everything. Layer them.
- Regex and checksums. Fast, deterministic, zero network. Great for structured identifiers with a fixed shape: emails, phone numbers, credit cards (validate with the Luhn checksum to cut false positives), IBANs, SSNs. Useless for names and addresses, which have no fixed pattern. Start here because it is cheap and it catches the highest-severity leaks (card numbers, SSNs) with high precision.
- Named entity recognition (NER). Statistical models like spaCy's pipelines, the engine under Presidio, that tag
PERSON,LOCATION,ORG,DATE. This is how you catch names and addresses that regex cannot. Trade-off: NER models miss unusual names, non-Western name orders, and misspellings, and they raise false positives on capitalized common words. Precision and recall both sit well below perfect, so treat NER as a strong signal, not a guarantee. - LLM-based detection. Use a separate, cheap model call whose only job is to find PII spans and return them as structured JSON. It handles context regex and NER miss ("my sister's maiden name is...", "the account ending in the last four you have on file"). Trade-offs: it costs a second call, adds latency, and, ironically, sends the raw text to a model to find the PII you were trying to keep from models. Only viable if that detection call goes to a provider or self-hosted model you already trust with the raw data, ideally one with zero retention.
- Denylists and gazetteers. Exact-match lists of known sensitive values: your employee roster, customer names from your own database, internal project codenames. Perfect precision on entries you control, and a good backstop for the specific identities you most care about.
The production pattern is a pipeline: regex and checksums first for the crisp high-severity stuff, then NER for names and places, then optionally an LLM pass for the ambiguous remainder, then a denylist sweep for your own known values. Union the spans, merge overlaps, redact once.
Measure it, because "it feels covered" is not a control
Redaction that you never measure will drift and rot. Two error types matter and they pull in opposite directions:
- False negatives (missed PII) are the security failures. A single missed SSN in a logged prompt is a reportable incident.
- False positives (over-redaction) are the quality failures. Redact a product name the model needed and the answer degrades.
Build a labeled evaluation set from realistic inputs: real ticket text with the PII hand-annotated, synthetic examples covering edge cases (international phone formats, hyphenated names, addresses without a ZIP, PII split across lines). Run your pipeline against it in CI and track recall per entity type. Set a hard floor for the high-severity types: recall on card numbers and government IDs should be as close to total as you can get, and a regression below your threshold should fail the build.
Do not chase a single aggregate score. A pipeline can post great average recall while quietly missing every passport number. Report per-type, weight by severity, and alert on the categories that would trigger a breach notification.
Operational details engineers get wrong
- Redact before caching, not after. Prompt caches key on the prompt text. If you cache the raw prompt and redact after, the cache is a PII store. Redact upstream so only clean text is ever keyed and stored.
- Redact streaming output too. If you stream completions token by token to the browser, you cannot run a whole-response detector at the end. Either buffer enough to run restore-and-scan on complete lines, or accept that streaming raw model output needs its own guard. A placeholder that spans a token boundary can arrive in pieces.
- Watch the vector store. Embeddings created from un-redacted text leak PII in two ways: the stored source chunk usually sits right next to the vector, and embeddings themselves can be partially inverted back toward their input. Redact before you embed for retrieval.
- Handle non-English text. NER quality drops on languages your model was not trained for, and phone and ID formats vary by country. If you serve multiple locales, test detection per language and load the right models. A detector tuned only for English gives false confidence on everything else.
- Keep the mapping off the wire and short-lived. The placeholder-to-value map is the crown jewels: it is literally the deanonymization key. Hold it in request-scoped memory, never log it, never persist it beyond the request, and never send it to the model or to a downstream service.
- Fail closed. If the detector errors or times out, do not fall through and send raw text. Drop the request or route it to a safe fallback. A redaction step that silently no-ops under load is worse than none, because you thought you were covered.
A minimal end-to-end flow
Putting the pieces together, a single guarded LLM call looks like this in pseudo-real code:
def guarded_completion(user_text, call_model):
# 1. Detect and redact before anything leaves the box
redacted, mapping = redact_manual(user_text)
# 2. Only redacted text is logged
log.info("prompt", text=redacted)
# 3. Call the provider with clean text
raw_response = call_model(redacted)
# 4. Log the raw model output too, it can contain PII
safe_log_output, _ = redact_manual(raw_response)
log.info("completion", text=safe_log_output)
# 5. Restore for the human, then scan for orphans
restored = restore(raw_response, mapping)
if re.search(r"\[[A-Z_]+_\d+\]", restored):
restored = re.sub(r"\[[A-Z_]+_\d+\]", "[redacted]", restored)
return restoredNotice the completion is redacted independently before logging, using a fresh detection pass, not the ingress mapping. The model can introduce new PII the input never contained, so the log guard cannot rely on the input's placeholder map. This is the detail that separates a demo from a system that survives an audit.
Compliance context, in plain terms
You are not the arbiter of what is legal, but you should know the shape of the obligations your redaction supports:
- GDPR treats personal data broadly and gives special categories (health, biometrics, and more) extra protection. Redaction reduces the personal data you process and store, which shrinks your risk surface and can support data-minimization requirements.
- HIPAA governs protected health information in US healthcare contexts. If your LLM feature touches patient data, redaction before third-party processing is often part of how you avoid an impermissible disclosure.
- Sector and regional rules (PCI DSS for card data, state privacy laws, sector regulators) layer on top.
The engineering takeaway is stable across all of them: minimize the personal data that crosses trust boundaries, log where it flows, and be able to show your detection works. Redaction is one control in that story, not the whole story. Pair it with access controls, retention limits, a data processing agreement with your model provider, and a clear record of what you send where.
FAQ
Should I redact PII before or after sending it to the LLM? Before. Redact between your ingress and the provider call so the model never receives raw personal data and your logs never store it. Restore real values only at the display layer, after all processing, right before a human reads the response. Redacting after the fact means the un-redacted prompt already traveled and got copied.
Can I just prompt the model to not repeat PII? No, not as your only control. A system instruction like "do not echo personal data" is a soft preference the model can ignore, and it does nothing about the raw prompt sitting in the provider's logs, your observability tool, or your cache. Prompt instructions are a weak complement to a deterministic redaction step, never a replacement.
Does redaction hurt answer quality? It can, if you over-redact. That is why redact-then-restore exists: replace PII with stable placeholders so the model keeps the structure it needs to reason ("email [EMAIL_1] about [PERSON_1]'s order") without seeing the real values. Tune your detector to avoid stripping non-personal tokens the model relies on, and measure false positives, not just misses.
Which library should I start with? Microsoft Presidio is the common open-source starting point: it bundles regex and checksum recognizers with spaCy NER and gives you an anonymizer for reversible masking. Cloud providers also offer managed PII detection APIs. Whichever you pick, layer regex and checksums for high-severity structured identifiers under the statistical detector, and add a denylist for the specific names and values you control.
How do I know my redaction is good enough? Build a labeled evaluation set of realistic inputs with the PII hand-annotated, run your pipeline against it in CI, and track recall per entity type. Set a hard floor for high-severity categories like card numbers and government IDs, and fail the build on regressions. An aggregate score hides category-specific holes, so always report per type and weight by severity.
What about PII in the model's own output? Treat the completion as untrusted and run an independent detection pass on it before logging or storing, using a fresh detector rather than the input's placeholder map. Models can surface personal data the input never contained, whether inferred, hallucinated, or pulled from retrieval. Also scan the restored output for orphan placeholders and strip any that the model invented.
Where does the placeholder-to-value mapping live? In request-scoped memory only. It is the deanonymization key, so never log it, never persist it past the request, and never send it to the model or any downstream service. If that map leaks, your redaction is undone, so guard it like a credential.
Do I still need redaction if I self-host the model? Yes, though the threat model shifts. Self-hosting removes the third-party egress concern, but prompts still land in your logs, caches, evaluation datasets, and vector stores, and those are all places a personal-data breach can originate. Redaction for logging and storage remains valuable even when the model runs entirely inside your own infrastructure.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.