Every LLM feature introduces a data flow your privacy review has probably never looked at. Your architecture diagram shows a friendly box labelled 'the model'. A regulator sees a transfer of personal data to a third-party processor, usually across a border, and asks three questions: which fields, on what legal basis, and retained for how long. 'We send the prompt to the provider' is not an answer to any of them.
I have taken AI features through security review at companies whose customers are banks and property managers — people whose day job is asking exactly those questions. These are the controls that actually got signed off, in the order they matter.
Treat the prompt path as an egress
Start by inventorying which fields can physically reach a prompt, not which ones you intended to send. In every system I have audited the answer was wider than the team believed, because of interpolated context: a support ticket body, a CRM note field, an error message carrying a row dump. Once you have that list, classify it the same way you classify any other egress, and put the classifier before the network call rather than in a design document.
Retrieval widens the path again. A RAG chunk is a data flow, and the retriever does not know your access model — it will happily surface a chunk from a document the user was never entitled to read. That is not a model problem; it is an authorization bug that happens to be expressed in vectors.
Redact on the way out, rehydrate on the way back
For most enterprise features the model does not need the real values. It needs the shape of the text. Swap identifiers for stable placeholders before the call, keep the mapping in your own memory, and restore it in the response. The user sees a normal answer; the provider never sees a customer.
import re
from dataclasses import dataclass, field
PATTERNS = {
'EMAIL': re.compile(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+[.][A-Za-z]{2,}'),
'PHONE': re.compile(r'[+]?[0-9][0-9 ()-]{7,}[0-9]'),
'NIC': re.compile(r'[0-9]{9}[VXvx]'),
}
@dataclass
class Vault:
forward: dict = field(default_factory=dict)
reverse: dict = field(default_factory=dict)
def redact(text: str, vault: Vault) -> str:
for label, pattern in PATTERNS.items():
def swap(m):
raw = m.group(0)
if raw not in vault.forward:
token = '[' + label + '_' + str(len(vault.forward)) + ']'
vault.forward[raw] = token
vault.reverse[token] = raw
return vault.forward[raw]
text = pattern.sub(swap, text)
return text
def rehydrate(text: str, vault: Vault) -> str:
for token, raw in vault.reverse.items():
text = text.replace(token, raw)
return text
# The vault lives in your process for the life of one request, and nowhere else.
answer = rehydrate(llm.complete(redact(message, vault)), vault)Two honest caveats. Regex catches formats, not people — names, addresses and free-text descriptions need an NER pass or a smaller local model, and you should measure recall on real data before claiming coverage. And pseudonymization is not anonymization: because you hold the mapping, the data is still personal data under GDPR. It reduces exposure to the processor; it does not remove your obligations.
Isolation is a retrieval property, not a UI property
In multi-tenant retrieval, the tenant filter has to be part of the query sent to the index, not a filter applied to the results afterwards. Post-filtering a top-k is the worst of both worlds: it quietly returns fewer results than the user should get, and one missing filter in one code path leaks across tenants. Per-tenant namespaces or collections are stronger still, because the failure mode becomes 'empty results' rather than 'someone else's contract'.
- Filter inside the index query; never post-filter a result set you already retrieved.
- Derive the tenant from the session server-side, never from a parameter the client can set.
- Keep an authorization eval set: questions whose correct answer is a refusal, run in CI like any other test.
- Re-check permissions at generation time — documents get reclassified after they are embedded.
Erasure is where embeddings bite
The first deletion request will find every shortcut you took. An embedding derived from personal data is still personal data, and so is the cached completion, the eval fixture someone copied into a repo, and the prompt sitting in your log store. If you cannot go from a source record to every vector, cache entry and log line derived from it, you cannot honour a deletion request — you can only promise to.
Build that lineage on day one, because retrofitting it is miserable. Store the source record ID on every chunk, every vector's metadata, and every cached response key. Then deletion is a fan-out over an ID you already have, and you can demonstrate it to an auditor in a query rather than a paragraph.
Your logs are the leak
The breach in an LLM system rarely comes from the model provider. It comes from the debugging you added the week you were shipping: full prompts and responses attached to traces, shipped to a third-party APM, retained for ninety days, readable by everyone with an engineering login. That is a second processor, a second transfer, and a second retention obligation — usually one nobody documented.
The leak is almost never the model. It is the trace you kept for debugging and forgot to expire.
Redact at the SDK boundary so raw text cannot reach the tracer, sample prompt bodies rather than keeping all of them, put them in a separate store with a short TTL and real access control, and make the retention a config value someone reviews. None of this is exotic. It is the same data hygiene we already apply to payment logs — the only new thing is that the sensitive field is now a paragraph of prose instead of a card number.
Treat prompt injection as the authorization bug it is, keep the model's ambient authority near zero, and most of the scary AI-specific scenarios collapse back into ordinary security problems you already know how to solve.