DEV Community

Cover image for I Masked the User's Message and the Email Still Left: 7 Places PII Slips Out of a .NET 10 LLM Pipeline
MICHAEL-MAURICE
MICHAEL-MAURICE

Posted on

I Masked the User's Message and the Email Still Left: 7 Places PII Slips Out of a .NET 10 LLM Pipeline

The idea is simple, and I liked it immediately. Before a chat message goes to the model, swap every email, phone number, IBAN and card for a typed placeholder like [EMAIL_1]. Keep the mapping in a per-conversation vault on your side. When the answer comes back, swap the values back in. The model still knows "this is an email, the same one as before", which is all it needs to call lookup_customer.

I built that as a DelegatingChatClient on Microsoft.Extensions.AI 10.11.0 for a support assistant. Masking the user's message took an afternoon. Then I started checking what actually left the process, and found personal data in places I hadn't thought of as "the prompt" at all.

Here are the seven places, roughly in the order I found them. The code is trimmed to the shape, and the trimmed parts are marked.

Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. The complete, working, tested version of this PII shield is inside, with full source and an in-depth PDF guide. Join Tech Skill Builder

Place 1: Round two of function calling

My first version masked user text and rehydrated tool arguments, so lookup_customer got the real email. Correct. Then FunctionInvokingChatClient built the next request to the model. It contained:

  • the assistant's function call, with the arguments it actually ran with (the real email)
  • the tool result, raw: the customer's name and phone number

So the second round carried the email back to the provider inside the assistant's own tool call. Masking "the prompt" isn't enough. The shield masks every message on every round: user text, earlier assistant turns, function-call arguments, function results, the instructions, even AuthorName.

Where the shield sits decides most of this:

new ChatClientBuilder(model)
    .UseFunctionInvocation(new CappedLoggerFactory(loggerFactory, LogLevel.Debug))  // outermost: tools get real values
    .UsePiiShield(engine, auditor)                                                    // masks every round
    .UseLogging()                                                                      // innermost: only sees placeholders
    .Build(services);
Enter fullscreen mode Exit fullscreen mode

The first Use is the outermost client. Tools run on your server and need the real email, so function invocation stays outside. Anything the shield can't scan, like an image in a DataContent, is blocked by default, because no regex reads a photo of a passport.

Place 2: Tool results, after JSON escaping

Tool results come back as JSON. The obvious move is to serialize and run your detectors over the string. Here's default System.Text.Json output for a customer called Mariam:

{"name":"\u0645\u0631\u064A\u0645 \u062D\u0633\u0646","phone":"\u002B20 122 555 0199"}
Enter fullscreen mode Exit fullscreen mode

The name is escaped into \u sequences and the plus sign too. A text scan finds 122 555 0199 without the country code, and no name at all. The model decodes the escapes just fine.

Fix: walk the JSON and mask each value on its own, then write the result with the relaxed encoder. The engine's MaskToolResult turns that payload into {"name":"[PERSON_1]","phone":"[PHONE_1]"}. A card sent as a JSON number becomes a token string.

Place 3: Your logs

LoggingChatClient logs full message contents at Trace. Put it inside the shield and Trace shows placeholders. Put it outside and it shows the customer. In the demo I ran both orders on one message: inside, no raw email in the log; outside, the email is right there.

The sneaky one is FunctionInvokingChatClient. It has to stay outside the shield, and at Trace it logs every tool argument and every tool result, raw. A log filter in appsettings.json works until someone edits appsettings.json. I'd rather make it impossible, so that client gets a logger factory that refuses anything below Debug:

public sealed class CappedLoggerFactory(ILoggerFactory inner, LogLevel minimum) : ILoggerFactory
{
    public ILogger CreateLogger(string categoryName) => new Capped(inner.CreateLogger(categoryName), minimum);
    // Capped.IsEnabled(level) => level >= minimum && inner.IsEnabled(level)
    // (the wrapper and the rest of ILoggerFactory are in the complete project)
}
Enter fullscreen mode Exit fullscreen mode

Telemetry is safer by default: OpenTelemetryChatClient.EnableSensitiveData is off unless the OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT environment variable says otherwise. Know that variable exists before somebody sets it on the cluster while debugging.

Place 4: The model's own answer

This leak goes the other way, towards the user's screen. The vault knows the real card number, so a naive "put everything back" step will print it in full if the model writes [CARD_1]. In my demo the offline model tried exactly that: "The card on file is [CARD_1]. You can print it as [CARD_1], written exactly like that."

Fix: a reveal policy decided by the vault entry, never by the model's text:

public static RevealPolicy Default { get; } = new(e => e.Kind switch
{
    PiiKind.Card => Reveal.Partial,                                          // "card ending 1111"
    PiiKind.Iban when !e.Origins.HasFlag(PiiOrigin.User) => Reveal.Partial,  // came from a tool, not the user
    _ => Reveal.Full,
});
Enter fullscreen mode Exit fullscreen mode

The user saw "card ending 1111" twice. A prompt injection can change what the model writes. It can't change what the vault says. Tool arguments are not subject to this policy; tools run on your side and get the real value.

Streaming adds a twist here: the provider decides where chunks end, so [PER and SON_1] can arrive separately. A small StreamingRehydrator holds back only the tail after the last unclosed [, up to the longest placeholder (15 characters), so the user never sees half a token.

Place 5: Placeholders nobody issued

Two cases I didn't plan for:

  • The user types a token. If someone types [EMAIL_1], it could collide with a real entry. The shield rewrites typed tokens to (EMAIL_1), because the token namespace belongs to the vault.
  • The model invents a token. Models hallucinate placeholders too. A token the vault never issued, like [EMAIL_99] or one from another conversation, renders as [unavailable] and is counted, so you can alert on it.

Lower-cased tokens like [email_1] still resolve, because models do that. Bare EMAIL_1 without brackets doesn't. Guessing is how one customer's address ends up on another customer's screen.

Place 6: Values the detectors refuse to mask

Detection is deliberately strict: IBANs only when the mod-97 check passes, cards only when Luhn passes. That keeps garbage out of the vault. It also means a customer who types their IBAN with one wrong digit gets it sent to the provider unmasked. It's still somebody's bank account, give or take a digit.

Fix: a second, cruder net after masking. An EgressScanner looks for the shape of personal data in the outbound payload, ignoring placeholders and ISO dates:

public IEnumerable<string> Scan(string text)
{
    // strip placeholders, ISO dates and allow-listed patterns first
    // then report "EmailLike", "IbanLike" and "LongNumber" (9+ digits) findings, names only, never values
    // (full implementation in the complete project)
}
Enter fullscreen mode Exit fullscreen mode

If anything turns up, the request isn't sent. The demo output:

blocked: Outbound request still looks like it contains personal data (IbanLike, LongNumber); not sent.
requests that reached the model: 0
Enter fullscreen mode Exit fullscreen mode

The API answers with a 422 and finding names. The price is false positives like a 12-digit tracking number, which is what the allow-list is for.

Place 7: The audit trail

You'll want to know how much personal data went through and whether it's the same customer as yesterday. The careless version logs the values "for debugging", and now your audit store is the leak.

Fix: record counts per kind and keyed fingerprints instead, using the HMAC redactor from Microsoft.Extensions.Compliance.Redaction:

services.AddRedaction(b => b.SetHmacRedactor(
    o => { o.KeyId = settings.AuditHmacKeyId; o.Key = key; },   // key from your secret store
    new DataClassificationSet(PiiClassifications.Personal)));
Enter fullscreen mode Exit fullscreen mode

The same email gives the same fingerprint in two conversations, and the email itself is never stored. One trap from my spike: NullRedactor returns its input unchanged, so never let it become the fallback for personal data.

What this is not

It's pseudonymization, not anonymization. Under GDPR, data you can re-identify with extra information (your vault) is still personal data (Recital 26). What you get is less exposure: the provider, the logs and the traces see tokens.

Placeholders also break one thing on purpose: dates are personal data too, so by default they become tokens, and then the model can't tell whether an order is inside a 30-day return window. The project includes two ways around that, and the PDF guide covers them in a chapter of its own.

Pre-flight checklist for a PII shield

  • [ ] Every message is masked on every round, not only the user's text
  • [ ] Function invocation is outside the shield, logging inside
  • [ ] Tool results are masked per JSON value, not as escaped text
  • [ ] The function-invoking client can't log below Debug
  • [ ] The vault entry, not the model, decides how much the user sees
  • [ ] Typed tokens are neutralized, unknown tokens resolve to nothing
  • [ ] An egress scan blocks anything that still looks like personal data
  • [ ] The audit trail holds counts and HMAC fingerprints, never values

What I tested

The project has 231 passing tests (207 unit, 24 WebApplicationFactory integration tests), including the real OpenAI adapter against a local fake endpoint, and none needs an API key. A 12-scene CLI demo runs every place above against an offline model. The library behaviors I mention were checked with spikes against Microsoft.Extensions.AI 10.11.0 and .NET 10 and are pinned as tests. Names are detected from a list you already have (a CRM, for example), so strangers' names need a NER model as an extra detector.

Get the complete project

Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. This project is already inside: the full .NET 10 solution with the detectors, the per-conversation vault, the PseudonymizingChatClient with streaming, the reveal policy, token neutralization, the egress scanner, the capped function-invocation logging, HMAC audit fingerprints, a minimal API with SSE streaming, the demo and all 231 tests, plus a PDF guide that goes much deeper than this post, including the full answer to the date problem, with code. You also get the other complete, tested .NET + AI projects in the membership, with new ones added regularly.

Join Tech Skill Builder for $5/month

Which of these seven would your current setup have missed?

Top comments (0)