Developer guide · OpenAI
How to keep personal data out of your OpenAI API calls
Short answer
Find the personal data in each request before it leaves your server, replace every value with a placeholder the model can still reason about, then put the real values back into the response. You can write that code yourself, use an open-source library such as Presidio, or route requests through a masking gateway with a one-line base URL change.
It's late on a Friday and someone on the support team ships a feature that drafts refund replies with GPT. The prompt template pulls in the ticket, the customer's name, their email and the IBAN the refund goes to. It works well. It also means every refund request now sends a name, an email address and a bank account number to a third party, and nobody wrote that down anywhere.
This guide shows three ways to stop that, from twenty lines of Python to a gateway that does it for every request. All three follow the same idea: detect the value, swap it for a placeholder, send the placeholder, and swap the real value back into the answer.
What does OpenAI do with the data you send to the API?
It's worth knowing what you're protecting against. As of October 2026, OpenAI's data controls documentation says:
- No training by default. Data sent to the API isn't used to train or improve OpenAI models unless you explicitly opt in. That has been the default since March 1, 2023.
- Up to 30 days of abuse-monitoring logs. By default, abuse-monitoring logs are kept for up to 30 days, and they can contain prompts and responses. Longer retention applies where the law requires it.
- Zero data retention needs approval. Zero Data Retention and Modified Abuse Monitoring are available only to approved customers, on eligible endpoints such as Chat Completions, Responses and Embeddings. Files, Batches, Assistants, Vector Stores and fine-tuning still store application state.
- EU data residency exists, with conditions. A project can be pinned to Europe (EEA and Switzerland) through the
eu.api.openai.comdomain. It's set when you create the project and requires an approved retention control such as Zero Data Retention or Modified Abuse Monitoring.
Check the current documentation and OpenAI's enterprise privacy page before relying on any of this. Terms change.
None of it changes the basic fact: once a customer's name and IBAN are in a request body, a third party has them. Under GDPR that's a disclosure to a processor. It needs a lawful basis, a data processing agreement and an entry in your records of processing. Residency and retention controls make the disclosure safer. Not sending the data makes most of that conversation short.
Where does personal data hide in an OpenAI request?
Not only in the user's message. The request body your code builds is usually much bigger than what the user typed:
| Where it hides | Example | Why it's missed |
|---|---|---|
| User message | "My IBAN is CH93 0076 2011 6238 5295 7" | It's the obvious one, so teams stop here |
| Conversation history | The email from turn 2, re-sent on turn 40 | Chat Completions re-sends the whole history every time |
| Retrieved context | A contract chunk with a signatory's name | The user never typed it; your RAG pipeline added it |
| Tool results | A CRM lookup returning JSON with phone numbers | Your code appends it, after any input check has run |
| Tool-call arguments | {"email": "ada@example.com"} | It sits in structured fields, not in content |
| Secrets | An sk- key pasted into "why does this fail?" | Not personal data, but worse to leak |
Whatever you choose below has to look at the whole body, not just the last user turn.
Option 1: write the redaction yourself
For one or two data types, a few lines of code will do. Here's card-number redaction in Python. The checksum is the important part:
import re
# 13 to 19 digits, optionally separated by single spaces or hyphens
CARD = re.compile(r"\b\d(?:[ -]?\d){12,18}\b")
def luhn_ok(digits: str) -> bool:
total, parity = 0, len(digits) % 2
for i, ch in enumerate(digits):
d = int(ch)
if i % 2 == parity:
d *= 2
if d > 9:
d -= 9
total += d
return total % 10 == 0
def redact_cards(text: str) -> str:
def repl(m: re.Match) -> str:
digits = re.sub(r"\D", "", m.group())
return "[CARD]" if luhn_ok(digits) else m.group()
return CARD.sub(repl, text)
print(redact_cards("Card 4111 1111 1111 1111, order 1234567890123"))
# Card [CARD], order 1234567890123Without luhn_ok, the pattern flags every long order number, tracking code and phone number. With it, the test card number becomes [CARD] and the 13-digit order number next to it survives. It's not perfect: about one random digit string in ten passes a Luhn check by chance, so long numeric IDs still trip it now and then. Looking for words like "card" or "visa" nearby cuts that down.
The trouble starts when the list grows. IBANs have their own checksum (mod 97). National IDs differ in every country and most carry a check digit. Names have no pattern at all. API keys come in hundreds of formats. A zero-width space typed inside a card number defeats the regex completely. And look at what the code does with the value: it throws it away. The model sees [CARD], which is fine for a summary and useless if the reply has to say "your card ending 1111".
Option 2: use Microsoft Presidio
Microsoft's open-source Presidio gives you an analyzer with dozens of recognizers plus a named-entity model for names, and an anonymizer that applies the change. A typical flow analyzes the prompt, anonymizes it, sends it to OpenAI and, if you used the encrypt operator, decrypts the placeholders in the answer.
It's a solid choice for batch jobs and prototypes, and it's strong on names in English prose. For live traffic you still own the rest: parsing each provider's request format, tool calls, streaming responses, country-specific IDs, secrets, and running the analyzer service at the latency your app needs. We compare the two in detail on the Presidio alternative page.
Option 3: route requests through a masking gateway
A gateway sits between your app and OpenAI. You change the client's base URL and the API key. Every request is masked on the way out and, with restoring switched on for the key, the real values go back into the response on the way in, streamed responses included. With Maskflare it looks like this:
from openai import OpenAI
client = OpenAI(
base_url="https://YOUR_GATEWAY_HOST/v1/openai", # was: https://api.openai.com/v1
api_key="mf_live_...", # your Maskflare key
)
reply = client.chat.completions.create(
model="gpt-4.1",
messages=[{"role": "user", "content":
"Write a short apology to Ada Lovelace (ada@example.com) "
"about the delayed refund to IBAN CH93 0076 2011 6238 5295 7."}],
)
print(reply.choices[0].message.content)
# OpenAI received mf_mask_ tokens in place of the name, email and IBAN.
# The text you print has the real values back in it.Your OpenAI key is stored once in the Maskflare console, or sent per request in pass-through mode. The gateway reads every text field in the body: system and user messages, tool and function-call arguments, and tool results, across Chat Completions, the Responses API and other endpoints under /v1/openai. Base64, hex and percent-encoded runs are decoded and checked too. Embeddings inputs get deterministic tokens so the same value always embeds the same way, and Batch files uploaded through the Files API are masked line by line. A rule set to block stops the request with HTTP 403 and the matching rule keys, never the values.
In TypeScript, or without a model in the loop
If you'd rather keep calling OpenAI directly, or you need to mask text that isn't going to a model at all (logs, a data pipeline, a ticket export), use the SDK. It takes a Redaction-purpose key, which can mask and unmask but can't forward AI traffic:
import OpenAI from 'openai';
import { MaskFlare } from '@maskflare/sdk';
const mf = new MaskFlare({
apiKey: process.env.MASKFLARE_API_KEY!, // a Redaction-purpose key
baseUrl: process.env.MASKFLARE_BASE_URL!,
});
const openai = new OpenAI();
const { output } = await mf.mask('Draft a reply to ada@example.com about invoice 4471');
// output: "Draft a reply to mf_mask_170b8521c1b944b3beb9ca02b328efbe about invoice 4471"
const completion = await openai.chat.completions.create({
model: 'gpt-4.1',
messages: [{ role: 'user', content: output }],
});
const reply = await mf.unmaskText(completion.choices[0].message.content ?? '');
// "...ada@example.com..." back in placeThe Python SDK has the same calls: mask, unmask and unmask_text, on MaskFlare or the asyncio AsyncMaskFlare. Install it with pip install maskflare.
Which option fits which situation?
| Approach | Good for | You own | Restores values |
|---|---|---|---|
| Your own regex | One or two formats, internal scripts | Every pattern and every false positive | No, unless you build a vault |
| Presidio | Batch jobs, prototypes, English names | Analyzer hosting, provider formats, streaming | With the encrypt operator, wired in by you |
| Masking gateway | Production traffic to one or more providers | Policy: which rules apply per environment | Yes, per gateway key, streaming included |
| SDK | Logs, pipelines, custom flows | Where you call mask and unmask | Yes, through unmask and unmaskText |
What bites in production?
- Streaming. A placeholder can arrive split across two chunks. Whatever restores values has to hold back just enough text to put it back together. The restoring guide shows how.
- Embeddings. If you embed masked text, the same value has to get the same token every time, or search stops matching. Use deterministic tokens for anything you index.
- Tool calls and JSON mode. Personal data sits in function arguments and structured outputs, not only in
content. Restore after parsing JSON, not before, so a value with a quote in it can't break the document. - Fail open or fail closed? Decide what happens when masking can't run. For embeddings and batch jobs, fail closed. For chat it's a business decision. Write it down and test it.
- Your own logs. Teams mask the request to OpenAI and then log the raw prompt in their own app. Mask before you log too, or wrap values in the SDK's
Sensitivetype so they print asSensitive(***). - Restoring other people's tokens. In a multi-user app, restore only the tokens your own mask call produced for that request. Otherwise a user can paste someone else's token and get their value back.
What doesn't masking solve?
Be honest with yourself about the edges. Masking works on text: the gateway doesn't read text inside images, and in Realtime voice sessions text events are masked but audio isn't. Context can still identify someone: "the founder of a 12-person bakery in Zurich" contains no detectable value and still points at one person. And reversible masking is pseudonymization, so the data is still personal data on your side.
Detection quality matters most. Maskflare's engine combines deterministic rules (patterns, checksums, context words and word lists) with a local named-entity layer: 574 detectors in 53 packs, including 47 country packs and 232 credential formats. Person names are found by word lists, context and the named-entity layer; test unusual names on your own documents. If your data has identifiers no generic detector knows, add custom rules or Exact Data Match for your own records. Masking adds about 2 ms (median) to a 10 KB chat request with the default rules.
Frequently asked questions
Does OpenAI train on API data?
Not by default. As of October 2026, OpenAI says API data isn't used for training unless you opt in, and abuse-monitoring logs are kept for up to 30 days. Check the current data controls page before relying on it.
Is redacting prompts enough for GDPR?
It reduces what you send, which is the point of data minimization. If you mask reversibly, the data is pseudonymized: still personal data on your side, but the provider receives no direct identifiers. It doesn't replace a lawful basis or a DPA. This is general information, not legal advice.
Will the model's answer still make sense?
Yes, if you use stable placeholders instead of deleting values. The model writes "Dear mf_mask_170b...", and the restore step turns it back into "Dear Ada".
Does OpenAI's EU data residency make masking unnecessary?
No. Residency controls where OpenAI processes and stores the data. The data still reaches OpenAI. Masking decides whether the personal data reaches it at all. Many teams use both.
How much latency does masking add?
With Maskflare's default rules, about 2 ms (median) for a 10 KB chat request, 4.3 ms at p99. Network time to the gateway comes on top, so measure from where your app runs.
About the author
Guides from the engineers who build Maskflare's gateway, detection engine, desktop agent and SDKs. Every code sample is run against the product before it's published, and every limit we mention is one we hit ourselves.