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.
