How do you redact sensitive data before sending it to OpenAI or Anthropic?
Run the redaction in your own process, on the string you're about to put in the request body, before you call openai.chat.completions.create() or anthropic.messages.create().
That sentence contains the whole argument, and the reason it has to be said is that the alternatives all run too late. A provider's privacy filter runs after your bytes crossed the wire, because it's the provider running it. A zero-data-retention agreement is a promise about what happens to the bytes after they arrive, not a reason they didn't arrive. A cloud scanner that classifies your prompt for you has received your prompt to classify it, which means you now have two vendors holding the string instead of one.
The mechanism is the point. Anything that happens on the other end of the connection happens after the transfer, and the transfer is the event you're trying to avoid.
When do you redact, and when do you refuse?
Redaction and blocking answer different questions, and mixing them up is the most common design error on this path.
Redact when the turn should continue without the value. The model needs the sentence, not the card. Replace the span with a placeholder, send the placeholder, and put the original back afterwards if the interface needs it.
Block when the value should never have been in a model request at all. A payments assistant that receives a full card number should fail the turn, not helpfully continue with <Redacted credit-card #1>. Redaction leaves a product that quietly accepts card numbers all day and strips them; blocking is the control that says this doesn't belong here.
Most applications need both, keyed by entity type. Redact names and email addresses so the conversation works. Block government identifiers and card numbers so the product isn't a collection point for them. For how to choose per class, see how to stop users sending PII to an LLM.
What does reversible redaction look like?
A local redact step returns two things: the stripped string, and a function that restores the originals.
import { redact } from "@arcjet/redact";
const [redacted, unredact] = await redact(message, { entities: ["email", "phone-number", "credit-card"],});
// The provider sees "Email <Redacted email #1> about order 4471".const completion = await openai.chat.completions.create({ model: "gpt-5", messages: [{ role: "user", content: redacted }],});
const text = completion.choices[0]?.message?.content ?? "";
// Restore only where the interface genuinely needs the original value.const reply = unredact(text);The placeholder is stable within the call, so <Redacted email #1> refers to the same address everywhere it appears and the model can reason about it as an entity. That's what makes reversible redaction work for a conversation: the assistant can say "I'll send the confirmation to that address" without ever having seen the address.
Two decisions worth making deliberately.
Restore for the interface, not for the model. unredact on the way back is for the human reading the reply. If the completion is going into a log, a trace, an embedding, or another provider call, restoring the value re-creates the exposure you just avoided. Keep the redacted version for those paths.
Decide whether restoration is safe for this entity type at all. If you redacted a card number because it must never leave, restoring it into a rendered reply puts it into a page that gets screenshotted into a support ticket. Irreversible replacement is the right choice for the classes you'd have blocked if the turn hadn't been worth saving.
Custom entity types are supported through a detect function, which receives the tokenized input and returns an entity type or undefined per token. That's how internal identifiers get covered: an employee number, a medical record number, or an account reference with a format that only exists in your system. A replace function controls what the placeholder looks like when the default format collides with your prompt template.
How do you make this apply to every call?
Redaction that lives in one route gets forgotten in the next one. Put it in a wrapper and make the wrapper the way your application talks to the provider.
import { redact } from "@arcjet/redact";import OpenAI from "openai";
const openai = new OpenAI();
const REDACTED_ENTITIES = ["email", "phone-number", "credit-card"] as const;
/** Redacts every message before the call and restores the reply. */export async function createCompletion(params: { model: string; messages: { role: "user" | "assistant" | "system"; content: string }[];}) { const restorers: ((text: string) => string)[] = [];
const messages = await Promise.all( params.messages.map(async (message) => { const [content, unredact] = await redact(message.content, { entities: [...REDACTED_ENTITIES], }); restorers.push(unredact); return { ...message, content }; }), );
const completion = await openai.chat.completions.create({ model: params.model, messages, });
const text = completion.choices[0]?.message?.content ?? "";
// Restore against every message's mapping: the model may refer to a // placeholder that was introduced several turns earlier. return restorers.reduce((value, restore) => restore(value), text);}For Anthropic the shape is the same, with the system prompt handled separately because it isn't in the messages array:
import Anthropic from "@anthropic-ai/sdk";
const anthropic = new Anthropic();
const message = await anthropic.messages.create({ model: "claude-sonnet-5", max_tokens: 1024, system: redactedSystemPrompt, messages: redactedMessages,});Then make the raw client hard to reach. Export the wrapper, keep the client module-private, and add a lint rule that fails on a direct import of the provider SDK outside that module. A convention that relies on everyone remembering has a shelf life of about one sprint.
What changes when the response streams?
The input side doesn't change at all. You've already redacted the request body before the connection opens, and streaming is a property of the response.
The output side is where it gets awkward, because a placeholder or a detected span can straddle a chunk boundary. <Redacted ema arrives, then il #1>. A per-chunk scan sees neither.
Three workable approaches, in increasing order of how much latency you're willing to trade:
Buffer a tail. Hold back the last N characters of the stream rather than emitting them immediately, and scan across the join. N needs to exceed your longest placeholder or expected entity. This keeps streaming visible to the user and delays the final fragment by one chunk.
Scan on a sliding window. Keep a rolling buffer of the last few chunks, run detection across the window, and emit whatever is behind the window. Same idea, more robust to long entities, slightly more code.
Buffer the whole completion. Scan once, then emit. Correct and simple, and it throws away the reason you were streaming. Reasonable for a short structured response, wrong for a long-form one.
Whichever you choose, the failure mode to design for is the one where you've already emitted the first half of a card number before the detector fires. If the classes you're scanning for on the output path are ones that must never render, buffer. Streaming is a UX preference; that isn't.
Reversible redaction and streaming interact badly if you restore per chunk, because the placeholder is split. Restore after the stream completes, or restore only on a fully buffered value.
What about tool call arguments?
The redaction wrapper covers the messages array. It doesn't cover what happens next.
When a model returns a tool call, the arguments are a string the model wrote, and they're about to be passed to your handler and from there to whatever the tool talks to. They may contain values from earlier in the conversation, including values you redacted on the way in and restored on the way out. That round trip is how a redacted address ends up in a CRM note.
Two things follow. First, run detection on tool arguments inside the handler, before the side effect. Second, be careful about restoring placeholders in anything that isn't going straight to a human, since restoring inside a tool argument un-does the redaction at exactly the boundary where it mattered.
For the full treatment of the tool-call path, see how to prevent PII leakage from AI agents.
What about retrieved context and system prompts?
Everything in the request body is the request body. The model doesn't distinguish between text a user typed and text your retrieval step attached, and neither does the provider's storage.
Retrieved chunks are the most commonly missed piece, because the application trusts its own database. A support document containing a customer's email address is a document your application fetched legitimately and is now transmitting. Redact retrieved context on the same path as user input. For more information about that boundary, see how to secure a RAG application.
Few-shot examples in a system prompt are the other one. Examples built from real conversations carry real data, and they're sent on every single request rather than once.
What redaction doesn't solve
Being clear about the limits is what makes the rest usable.
Redaction depends on detection, and detection has false negatives. Structured formats with checkable properties, such as card numbers with a Luhn check, are reliable. Names, addresses, and free-form identifiers are much less so, and the rate moves as your traffic changes. That's an argument for measuring on your own corpus and for pairing redaction with controls that don't depend on recognizing content.
Redaction also doesn't make the provider relationship go away. You're still sending prompts to a third party, still need a lawful basis for it, and still need to answer where the data goes. What redaction changes is what's in the payload. For how that maps to specific obligations, see GDPR and CCPA compliance for LLM applications.
And a placeholder in the prompt does nothing about the copy in your own logs, traces, and vector store. Those accumulate from the unredacted side of the wrapper. For more information, see how to redact sensitive data from logs.
Checklist
- Redact in your process, before the request body is built.
- Choose block or redact per entity class rather than globally.
- Put the redaction in a wrapper, and make the raw provider client hard to import.
- Redact retrieved context and few-shot examples, not only user messages.
- Restore placeholders for the interface, never for another provider call, a log, or an embedding.
- Buffer a tail on streamed output so a span can't straddle a chunk boundary.
- Check tool call arguments inside the handler, before the side effect.
- Measure detection on your own traffic in dry run before you enforce.
Frequently asked questions
How do you redact sensitive data before sending it to OpenAI or Anthropic?
Run the redaction in your own process on the string you are about to put in the request body, before calling openai.chat.completions.create() or anthropic.messages.create(). Anything that happens on the other end of the connection happens after the transfer, and the transfer is the event you are trying to avoid.
Do OpenAI's or Anthropic's own privacy filters solve this?
They run after the bytes crossed the wire, because the provider is the one running them. A zero-data-retention agreement is a promise about what happens to the data after it arrives, not a reason it did not arrive. Both are worth having and neither replaces a check in front of the call.
Should redaction be reversible?
Reversible redaction returns a function that restores the originals, which is right when the interface needs the real value back. It also means you hold a mapping table, and that table is personal data with its own security and retention obligations. Use irreversible replacement for the classes you would otherwise have blocked.
How do you redact a streaming response?
The input side does not change, because you redacted before the connection opened. On the output side a span can straddle a chunk boundary, so buffer a tail longer than your longest entity, scan a sliding window, or buffer the whole completion. If the entity types must never render, buffer: streaming is a UX preference and that is not.
Does redaction cover tool call arguments?
Not by itself. A message-level wrapper redacts what goes into the messages array. When the model returns a tool call, the arguments are a new string the model wrote, and they may contain values you restored on the way out. Check tool arguments inside the handler, before the side effect.
AI runtime security in your code
Protect your AI agent workflows with Arcjet
Get allow, deny, and redact on agent actions before the side effect.