Sensitive Data Handling
Handling sensitive data in AI workflows
Sensitive data — personally identifiable information (PII) such as names, email addresses, phone numbers, government ID numbers, and payment details — flows through AI applications constantly: a customer support assistant reads a customer's account details, a document-summarization tool processes contracts full of names and addresses, a data-analysis assistant queries a database of user records. This lesson covers the specific risks that arise when sensitive data reaches a prompt, and the concrete techniques for reducing exposure without breaking the functionality that needs the data in the first place.
Why sending raw PII to a model call is a distinct risk
Every request you send through the API leaves your own infrastructure and is processed by OpenAI's systems. That is true of any third-party API call, but it matters specifically for PII because of a few compounding factors:
- Data minimization principles — found in regulations like GDPR and CCPA, and simply good practice regardless of jurisdiction — hold that you should only transmit and retain the minimum personal data necessary for a given purpose. Sending a customer's full record when only their subscription tier is relevant violates this principle even if the transmission itself is otherwise secure.
- Retention and processing terms differ from your own systems. Your own database has retention and access policies you control directly. A third-party API call is governed by that provider's data usage policies, which may differ from what you need for compliance with a specific regulation or a specific customer contract.
- Prompts and completions can end up in logs (yours, and potentially the provider's, depending on API tier and settings) — which multiplies the number of places sensitive data physically exists, and therefore the number of places it could leak from.
- The model may echo sensitive data back into its output, which then flows into whatever your application does with that output — displaying it, storing it, logging it — extending exposure to systems that had no reason to touch the raw data at all.
None of this means you can never process PII through the API — plenty of legitimate applications need to. It means you should default to sending the least sensitive representation of the data that still lets the model do its job.
Redaction before the prompt is built
The most direct technique is redacting or masking sensitive substrings before they ever enter the prompt, and — where the workflow requires it — reinserting the real values afterward using a mapping your own code controls, so the model itself never sees the real value.
import re
REDACTION_PATTERNS = {
"EMAIL": re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"),
"PHONE": re.compile(r"\b\d{3}[-.\s]?\d{3}[-.\s]?\d{4}\b"),
"SSN": re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
"CREDIT_CARD": re.compile(r"\b(?:\d{4}[-\s]?){3}\d{4}\b"),
}
def redact(text: str) -> tuple[str, dict[str, str]]:
"""
Replaces sensitive substrings with placeholder tokens.
Returns the redacted text and a mapping of token -> original value,
so the caller can restore the real values later if needed.
"""
mapping: dict[str, str] = {}
counter = 0
redacted = text
for label, pattern in REDACTION_PATTERNS.items():
def replace_match(match, label=label):
nonlocal counter
counter += 1
token = f"[{label}_{counter}]"
mapping[token] = match.group(0)
return token
redacted = pattern.sub(replace_match, redacted)
return redacted, mapping
def restore(text: str, mapping: dict[str, str]) -> str:
"""Reinserts original values for any placeholder tokens still present."""
restored = text
for token, original in mapping.items():
restored = restored.replace(token, original)
return restored
redact scans the input text against a set of known PII patterns and replaces each match with a labeled placeholder token ([EMAIL_1], [PHONE_1], and so on), while remembering the real value behind each token in mapping. The model then sees only the redacted version — it can reason about the structure of the text ("this customer provided their email and phone number") without ever seeing the actual email or phone number. If your workflow needs the real values back in the final output (for example, generating a formatted letter that must contain the actual customer email), restore reverses the substitution using the mapping your own code held onto the whole time — the model never needed the real value to do its part of the job.
def test_redact_masks_email_and_phone():
text = "Contact John at john.doe@example.com or 555-123-4567."
redacted, mapping = redact(text)
assert "john.doe@example.com" not in redacted
assert "555-123-4567" not in redacted
assert "[EMAIL_1]" in redacted
assert "[PHONE_1]" in redacted
print("PASS: redact masks email and phone substrings")
def test_restore_reverses_redaction():
text = "Reach out to jane@example.com about the account."
redacted, mapping = redact(text)
restored = restore(redacted, mapping)
assert restored == text
print("PASS: restore reconstructs the original text from the mapping")
test_redact_masks_email_and_phone()
test_restore_reverses_redaction()
Using structured outputs to avoid unnecessary echoing
A second, complementary technique addresses a different part of the problem: even when the model legitimately needs some sensitive input (say, verifying that a provided email matches an account on file), you don't want the model repeating that sensitive value back inside free-form prose in its output, because free-form output is harder to control and more likely to end up displayed or logged somewhere unintended. Constraining the model to a structured response — as covered elsewhere in this course for structured outputs generally — lets you define a schema that simply doesn't include a field for echoing raw PII back.
from pydantic import BaseModel
class AccountLookupResult(BaseModel):
account_found: bool
subscription_tier: str | None
# Deliberately no field for echoing the customer's email or phone back —
# the caller already has that data and doesn't need the model to repeat it.
def build_lookup_request(client, redacted_customer_text: str):
return client.responses.parse(
model="gpt-5.6-terra",
input=(
"Given the following redacted customer message, determine "
"whether it looks like a request for account status, and "
f"nothing else:\n\n{redacted_customer_text}"
),
text_format=AccountLookupResult,
)
By constraining the output shape with text_format, you remove the opportunity for the model to include a raw PII value in its response, because the schema has nowhere for it to go. This is a stronger guarantee than a prompt instruction like "don't repeat the customer's email," because it's enforced by the response's structure rather than by the model choosing to comply.
Note: Redaction patterns like the ones above are a practical baseline, not a formally complete PII detector. Regexes will miss unusual formats (international phone numbers, names, addresses) and can occasionally over-match. For applications with strict compliance requirements, pair this approach with a dedicated PII-detection library or service, and treat the regex-based approach shown here as one layer of a broader data-handling strategy rather than a complete solution by itself.
Deciding what actually needs to reach the model
Before reaching for redaction, ask a more basic question: does the model need the sensitive field at all? Many workflows send an entire customer record to the model when only one or two fields are relevant to the task. Trimming the payload down to exactly what's needed is a simpler and more reliable form of data minimization than redacting a large blob after the fact.
def build_minimal_context(customer_record: dict, task: str) -> dict:
"""Extracts only the fields relevant to a given task, dropping the rest."""
field_requirements = {
"shipping_status": ["order_id", "shipping_status", "estimated_delivery"],
"subscription_info": ["subscription_tier", "renewal_date"],
}
needed_fields = field_requirements.get(task, [])
return {k: v for k, v in customer_record.items() if k in needed_fields}
For a shipping_status task, this function drops the customer's name, email, payment details, and anything else not explicitly listed — none of it was ever going to be sent to the model in the first place, which is strictly safer than sending everything and redacting afterward, since data that was never transmitted cannot leak from the transmission.
Common Mistakes
- Redacting only the "obvious" fields and missing formats your regex doesn't cover. International phone numbers, alternate SSN formats, and names are notoriously hard to catch with simple patterns — treat regex redaction as a baseline, not a guarantee, especially for regulated data.
- Sending an entire database record when only one field is relevant. This is the most common and most avoidable source of unnecessary PII exposure — trimming the payload (as in
build_minimal_context) is often more effective than redaction after the fact. - Letting the model's free-form output become the only place sensitive values are checked or handled, instead of constraining output structure so there's no field for that data to occupy in the first place.
Best Practices
- Apply data minimization first: send only the fields a given task genuinely requires, before considering redaction of what remains.
- Redact known PII patterns before constructing the prompt, and keep the real-value mapping in your own code rather than ever exposing it to the model.
- Use structured output schemas to prevent the model from echoing sensitive values in free-form text, rather than relying solely on prompt instructions.
- Treat regex-based redaction as one layer of defense, and use a dedicated PII-detection tool for applications with real compliance obligations.