Redacting PII Before It Reaches the LLM in ASP.NET Core AI APIs

Search for a command to run...

No comments yet. Be the first to comment.
Most of the .NET conversation about the Model Context Protocol is about building servers: expose your API as MCP tools, point Claude or VS Code at it, done. That is the half that gets written about. T

The first RAG system I shipped answered beautifully about concepts and failed completely on part numbers. Ask it "how do I reset a stuck deployment" and it nailed the answer. Ask it "what does error P

AutoMapper has been the default object mapper in .NET for over a decade, and for most of that time nobody thought about it. Version 15.0.0 changed that. It ships under a dual license now, with a free

You built the pipeline. Documents are chunked, embedded, and sitting in a vector store. Retrieval returns results. And your RAG answers still hallucinate in .NET, confidently telling users about a stu

Coding Droplets
305 posts
Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.
From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.
The prompt is the leak. Every other control in an AI feature gets scrutinised - authentication on the endpoint, authorization on the tools, validation of the model's output - while the one thing that reliably carries customer data across an organisational boundary is a string nobody reviews. PII redaction for LLM calls in ASP.NET Core is the control that closes that gap, and in production I've seen exactly how it gets missed: a support-summarisation feature that "only sends ticket text" turns out to send ticket text containing full names, email addresses, phone numbers, and occasionally a card number a customer pasted into a chat box three years ago.
This is not a hypothetical compliance concern. That data lands in a third-party provider's request logs, in your own distributed traces, in your conversation-history table, and in your RAG index, and every one of those is a copy you now have to account for. The complete redaction pipeline, with the detector set, the reversible tokenizer, and the tests that prove nothing leaks, is available on Patreon as a working project.
Redaction only holds up when it is designed alongside the rest of the guardrail layer, because filtering the input is useless if the output path leaks the same data back. Chapter 16 of AI-Powered .NET APIs covers input and output filtering, PII handling, and the data-residency question of when a local model is the only correct answer, all against one running API.
When you send a prompt to a hosted model, you should assume the text is persisted somewhere outside your control until your contract says otherwise. But the provider is only the most visible copy. In a typical ASP.NET Core AI feature the same string is written to:
Your logs. Anyone who has debugged a bad completion has logged the full prompt "temporarily". That log ships to your aggregator and lives out its retention period.
Your traces. OpenTelemetry's GenAI semantic conventions can capture message content. It is off by default for exactly this reason, and it gets switched on during an incident and rarely switched off. We covered the instrumentation side in OpenTelemetry for AI endpoints in ASP.NET Core.
Your conversation store. Multi-turn chat means prompts are persisted by design, usually in the same database as everything else and rarely with a separate retention policy.
Your vector index. RAG ingestion embeds and stores document chunks. If those documents contain personal data, so does your index, in a form that is hard to search and harder to delete on request.
The regulatory framing matters here: a right-to-erasure request has to reach all five of those copies. If you cannot enumerate them, you cannot honour it.
This is what almost every first implementation looks like, and there is nothing obviously wrong with it:
// Vulnerable: raw customer text goes straight to a hosted model
var messages = new[]
{
new ChatMessage(ChatRole.System, "Summarise this support thread."),
new ChatMessage(ChatRole.User, ticket.FullConversationText)
};
var response = await _chat.GetResponseAsync(messages, options, ct);
The flaw is not in the code, it is in the absence of a boundary. There is no point in this call stack where anyone decided what class of data is allowed to leave. Add a logging decorator later and you have also, silently, decided that personal data belongs in your log aggregator.
Put redaction in a decorator over IChatClient, not at call sites. A call site can be forgotten; a decorator registered in the pipeline cannot.
builder.Services.AddChatClient(/* provider client */)
.Use(inner => new RedactingChatClient(inner, detectors, tokenMap))
.UseOpenTelemetry();
Because it wraps the inner client, everything downstream - including telemetry and caching - sees only redacted text. That ordering is the entire point, and getting it backwards is the most common implementation mistake.
Inside the decorator, three decisions:
1. Detect with layered detectors, not one clever regex. Structured identifiers such as email addresses, phone numbers, national IDs, and card numbers are reliably matched by pattern, and card numbers should be confirmed with a Luhn check to cut the false-positive rate dramatically. Unstructured PII - names, addresses, employers - needs named-entity recognition, and no regex will substitute for it. Be explicit about which categories you can and cannot catch, and write the gap down rather than implying full coverage.
2. Choose redaction or tokenization deliberately. These are different tools:
| Approach | What it does | Use when |
|---|---|---|
| Redaction | Replaces the value with a marker, irreversibly | The model never needs the real value |
| Tokenization | Replaces with a stable placeholder you can reverse | The answer must contain the real value |
| Hashing | Replaces with a deterministic digest | You need to correlate without reading |
Tokenization is what makes redaction usable for real features. Replace "Priya Nair" with [PERSON_1] on the way in, keep the mapping in request scope only, and substitute the real name back into the model's answer on the way out. The user sees a normal response; the provider never saw a name.
3. Use the platform's compliance primitives rather than inventing your own. .NET ships a redaction abstraction in Microsoft.Extensions.Compliance.Redaction, with a Redactor and IRedactorProvider resolved by data classification. Registering it through AddRedaction gives you one consistent policy that also applies to the logging pipeline, which is exactly the second leak path described above. Building a bespoke string-replacer means solving the same problem twice and keeping the two in sync forever.
It is tempting to run a cheap model first with "remove all personal data from the following text". This fails on its own terms: by the time the model can redact the text, the text has already left your perimeter. You have doubled your cost and moved the leak, not closed it.
The same reasoning applies to relying on system-prompt instructions such as "never repeat personal data". Instructions are not controls. A determined user, or a poisoned document in your RAG corpus, will find the phrasing that ignores them. That is the same class of problem we covered in preventing prompt injection in ASP.NET Core AI APIs.
Input redaction alone gives you a false sense of completion. Two output-side leaks are common:
RAG context reintroduces PII. Your retrieval step pulls document chunks that contain personal data and injects them into the prompt, downstream of your input redaction. Redact at ingestion time as well, or run retrieved chunks through the same boundary before they reach the model.
The model echoes what you sent it. If tokenization was partial, the answer can contain a real value alongside a placeholder. Scan responses with the same detectors before returning them, and treat a detection as an incident signal, not just a filter hit.
Treat everything the model returns as untrusted input, the same way you treat a request body. Our guide to sensitive data exposure in ASP.NET Core APIs covers the general discipline this borrows from.
Redaction is a decorator in the IChatClient pipeline, registered before telemetry and caching, and cannot be bypassed by a call site
Detector coverage is documented, including the categories it knowingly does not catch
Card-number matches are Luhn-validated to control false positives
Token maps live in request scope and are never persisted or logged
RAG ingestion redacts at index time, not only at query time
Model responses are scanned before they are returned to the caller
Prompt content is excluded from traces and logs by default, and the switch to enable it requires a code change rather than a config flag
Conversation history has its own retention policy and a deletion path that satisfies erasure requests
Data that legally cannot leave the tenant's region routes to a locally hosted model instead of being redacted and sent anyway
Redaction failures fail closed: if the detector throws, the call does not proceed
Layer two mechanisms. Use regular expressions for structured identifiers such as email addresses, phone numbers, and payment card numbers, validating card matches with a Luhn check. Use a named-entity recognition model for unstructured PII such as names and addresses, since no pattern can catch those reliably. Then apply .NET's Microsoft.Extensions.Compliance.Redaction abstractions so the same classification policy governs your logging pipeline as well.
That is a contractual and regulatory question rather than a technical one, and the answer differs between providers, deployment models, and regions. The engineering position that survives audit is to design as though the data is retained, redact by default, and reserve unredacted calls for deployments where your agreement, region, and retention settings have been reviewed and documented.
Redact when the model has no legitimate need for the value, which is most of the time. Tokenize when the answer must contain the real value, replacing each entity with a stable placeholder and substituting it back after the response returns. Keep the mapping in request scope only, because a persisted token map is a re-identification database and inherits every obligation the original data had.
No. Any model-based redaction happens after the text has already been transmitted, so the leak has occurred before the redaction runs. It also costs an extra call and gives you a probabilistic control where you need a deterministic one. Model-based detection is only defensible when the model runs locally, inside your own perimeter.
Place the redaction decorator before the telemetry decorator in the IChatClient pipeline so instrumentation only ever observes redacted text. Keep OpenTelemetry GenAI content capture disabled by default, and use the compliance redaction abstractions so log properties classified as personal data are redacted by policy rather than by developer discipline.
Redact at ingestion, because retrofitting is genuinely painful: embeddings cannot be reversed to remove a name, so the only real remedy is re-chunking, re-redacting, and re-embedding the affected documents. If you have an existing index built without redaction, treat re-ingestion as the fix and add the boundary before the next document lands.
I'm Celin Daniel, Co-founder of Coding Droplets. I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.
GitHub: codingdroplets
YouTube: Coding Droplets
Website: codingdroplets.com