Skip to content

Developer guide · OpenAI

How to keep personal data out of your OpenAI API calls

Maskflare EngineeringPublished 7 min read

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.com domain. 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 hidesExampleWhy it's missed
User message"My IBAN is CH93 0076 2011 6238 5295 7"It's the obvious one, so teams stop here
Conversation historyThe email from turn 2, re-sent on turn 40Chat Completions re-sends the whole history every time
Retrieved contextA contract chunk with a signatory's nameThe user never typed it; your RAG pipeline added it
Tool resultsA CRM lookup returning JSON with phone numbersYour code appends it, after any input check has run
Tool-call arguments{"email": "ada@example.com"}It sits in structured fields, not in content
SecretsAn 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:

Python: card numbers with a Luhn check
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 1234567890123

Without 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:

Python: OpenAI SDK through the Maskflare gateway
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:

TypeScript: mask, call OpenAI, restore
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 place

The 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?

ApproachGood forYou ownRestores values
Your own regexOne or two formats, internal scriptsEvery pattern and every false positiveNo, unless you build a vault
PresidioBatch jobs, prototypes, English namesAnalyzer hosting, provider formats, streamingWith the encrypt operator, wired in by you
Masking gatewayProduction traffic to one or more providersPolicy: which rules apply per environmentYes, per gateway key, streaming included
SDKLogs, pipelines, custom flowsWhere you call mask and unmaskYes, 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 Sensitive type so they print as Sensitive(***).
  • 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

Maskflare Engineering

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.

Keep reading

See it on your own data

Try Maskflare free.
Bring your hardest prompts.

28-day free trial or 10,000 requests, whichever comes first. Point one app at the gateway or enroll a few laptops,
and watch what gets masked. Or talk to us first.