.NETC#SecurityComplianceArchitecture

Redacting PII Before It Reaches the Model: A .NET Pattern

Once a customer record leaves your process and lands in a model provider log, you cannot get it back. Here is a redaction layer for .NET that sits in front of the model, survives tool calls, and does not quietly break your prompts.

Redacting PII Before It Reaches the Model: A .NET Pattern

There is a specific failure that is worth designing against, because it is not recoverable. A support-summarisation feature works fine in testing. It goes live. Six weeks later somebody notices that the prompts include the full customer record — name, address, order history, and in one case a partial card number — and every one of those prompts has been sitting in a provider’s logs since launch.

You cannot un-send that. You can change the code, sign an agreement, file a report. You cannot make the data not have left.

So the redaction layer belongs in front of the model, and it belongs somewhere a future colleague cannot skip.

Put it in the middleware chain, not in the calling code

Microsoft.Extensions.AI gives you a delegating IChatClient, which is the right shape for this. The rule you want is that no code path can reach the provider without passing through redaction — not because you trust nobody, but because a control that has to be remembered is a control that will be forgotten in a hurry on a Friday.

public sealed class RedactingChatClient(IChatClient inner, IRedactor redactor)
    : DelegatingChatClient(inner)
{
    public override async Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        CancellationToken ct = default)
    {
        // One vault per call. The mapping never leaves this method.
        var vault = new RedactionVault();
        var scrubbed = messages
            .Select(m => new ChatMessage(m.Role, redactor.Redact(m.Text ?? "", vault)))
            .ToList();

        var response = await base.GetResponseAsync(scrubbed, options, ct);

        // Re-hydrate on the way out so the caller sees real values.
        return vault.Restore(response);
    }
}

Registered once, it applies everywhere:

builder.Services.AddChatClient(sp =>
    new OpenAIClient(key).GetChatClient("gpt-4.1").AsIChatClient())
    .Use(inner => new RedactingChatClient(inner, sp.GetRequiredService<IRedactor>()))
    .UseFunctionInvocation()
    .UseLogging();

Order matters here and it is easy to get backwards. Redaction has to sit outside function invocation, so that tool results — which are usually the fattest source of personal data in the whole conversation — get scrubbed on their way back into the next model call. Put it inside and you will redact the user’s question and then cheerfully post the entire customer row from your own database.

Reversible placeholders, not blanking

The instinct is to replace personal data with [REDACTED]. Do not. It destroys the model’s ability to reason about the text, and it destroys it silently — the response gets vaguer and nobody can point at why.

Give each distinct value a stable token instead:

public sealed class RedactionVault
{
    private readonly Dictionary<string, string> _toToken = new(StringComparer.OrdinalIgnoreCase);
    private readonly Dictionary<string, string> _toValue = new();
    private readonly Dictionary<string, int> _counters = new();

    public string Tokenise(string value, string kind)
    {
        if (_toToken.TryGetValue(value, out var existing)) return existing;

        var n = _counters.GetValueOrDefault(kind) + 1;
        _counters[kind] = n;

        var token = $"[{kind}_{n}]";
        _toToken[value] = token;
        _toValue[token] = value;
        return token;
    }

    public string Restore(string text) =>
        _toValue.Aggregate(text, (acc, kv) => acc.Replace(kv.Key, kv.Value));
}

The model now sees this:

[PERSON_1] rang about order [ORDER_1]. [PERSON_1] says the delivery went to [ADDRESS_1] instead of [ADDRESS_2].

It can still tell that the same person appears twice and that two different addresses are involved — which is the entire substance of the complaint. It just cannot tell you who they are. Blanking would have produced three identical [REDACTED] markers and thrown the relationship away.

The consistency is the point. Same value, same token, every time it appears in the conversation.

What to detect, and what you will miss

Structured identifiers are the easy half, and regular expressions handle them well because the formats are formats:

private static readonly (string Kind, Regex Pattern)[] Patterns =
[
    ("EMAIL",  new Regex(@"\b[\w.+-]+@[\w-]+\.[\w.-]+\b", RegexOptions.Compiled)),
    ("IBAN",   new Regex(@"\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b", RegexOptions.Compiled)),
    ("CARD",   new Regex(@"\b(?:\d[ -]*?){13,19}\b", RegexOptions.Compiled)),
    ("PHONE",  new Regex(@"\b(?:\+44|0)\s?\d{3,4}\s?\d{3}\s?\d{3,4}\b", RegexOptions.Compiled)),
];

Two warnings about that card pattern. It will match things that are not cards — long reference numbers, some order IDs — and that is the correct bias for this job, because a false positive costs you a slightly vaguer prompt and a false negative costs you a card number in someone else’s log. And a digit-run regex on unbounded user input is a place to be careful about catastrophic backtracking; keep the quantifiers bounded and put a timeout on the match.

Names and addresses in free prose are the hard half, and patterns will not get you there. If your inputs are customer support transcripts, you need a named-entity model in the loop. If they are forms with typed fields, you already know which fields are personal and you should be redacting by field name, not by scanning the rendered text — that is both more reliable and considerably cheaper.

The honest position is that detection is never complete. Design as though something will get through, which mostly means: minimise what you put in the prompt in the first place, and do not send the whole row when the model needs three columns.

The tool-call hole

This is the one that catches people out. You redact the user’s message carefully, the model calls get_customer(id), your function returns the full record, and the framework feeds that record straight back to the model as a tool result. Everything you just protected is now in the payload anyway.

Because the redacting client sits outside function invocation in the chain above, tool results pass through it on their way back. But it is worth being deliberate rather than relying on ordering: have your tools return already-minimal data. A tool that returns thirty fields when the agent needed two is a data-protection problem before it is a token-cost problem — though it is also a token-cost problem.

Do not let it leak sideways into your own logs

Redaction protects the provider boundary. Your own telemetry is a second boundary and it is usually the leakier one, because it feels internal.

If you are capturing prompts in traces — and you probably should be — capture the redacted form. The RedactingChatClient above makes this natural: put UseLogging() and your OpenTelemetry instrumentation inside the redaction wrapper and they see scrubbed text, because the scrubbing happens before the call is delegated inward.

That ordering decision is worth a comment in the code, because it looks arbitrary and it is not.

What this costs you

Latency: a few milliseconds for pattern matching on a normal prompt, meaningfully more if you add a named-entity model, which is a real inference call of its own. Budget it deliberately rather than discovering it in production — the latency budget is easier to defend when every component in it was a choice.

Quality: small but non-zero. Tokens like [PERSON_1] are slightly awkward for models trained on natural text, and occasionally one will echo a placeholder into its answer in a clumsy way. Re-hydration hides most of that. Test it on your actual prompts rather than assuming.

Correctness: the one genuine risk is a placeholder colliding with real content. If your documents legitimately contain bracketed uppercase tokens, pick a delimiter that they do not.

Takeaway

Redaction is not a compliance programme, and anyone who tells you it is has not read the regulation. What it is, is the difference between a bad week and an unrecoverable one.

Three things carry most of the value. Put it in the middleware chain so it cannot be bypassed. Use reversible per-value tokens so the model can still reason and you can still show the user a real answer. Put it outside function invocation so tool results are covered, because tool results are where the personal data actually lives.

Then send less to begin with. Every field you do not put in the prompt is a field that cannot leak, cannot be logged and cannot be paid for.

If you are hardening an agent more broadly, securing AI agents in .NET covers the wider surface, and prompt injection defence covers the attack that redaction does nothing about.

Note: Data protection obligations depend on your jurisdiction, your lawful basis and your contracts with the model provider, none of which a code pattern can settle. Verify the provider’s data handling and retention terms for the specific endpoint and region you are calling, and have the compliance position reviewed by someone qualified before you rely on it.


Have a correction or a topic you want covered? Email mani.bc72@gmail.com.

Frequently asked questions

Is redaction enough for GDPR compliance?

No, and treating it that way is the mistake worth avoiding. Redaction reduces what you transmit; it does not by itself establish a lawful basis, a retention policy, a data processing agreement with the provider, or a subject access process. Treat it as one control in a set, and get the contractual side reviewed by someone qualified rather than inferring it from a blog post.

Should I use a regional endpoint instead of redacting?

Do both. A regional or in-tenant deployment addresses where data is processed. Redaction addresses what is in the payload at all. They solve different problems, and the second one still matters when a prompt ends up in a debug log, a trace exporter or a support ticket inside your own systems.

What happens when the model needs the real value to answer?

Then you re-hydrate on the way out, which is the whole point of using reversible placeholders rather than blanking the text. The model reasons over PERSON_1 and ORDER_2, and your code substitutes the real values into the final response. If a task genuinely requires the model to see raw identifiers, that is a decision to make deliberately, not by accident.

Do regular expressions catch enough?

They catch structured identifiers well — card numbers, national insurance numbers, IBANs, email addresses, anything with a format. They are poor at names and addresses in free prose. If your inputs are mostly customer support text, pair the patterns with a named-entity model. If they are mostly forms and identifiers, patterns alone get you a long way.

Where should this sit in the pipeline?

As a delegating IChatClient in the middleware chain, so it applies to every call including the ones a future colleague adds without reading this article. Anything you have to remember to call is a control that will eventually be forgotten.