Indirect Prompt Injection Hiding in a Retrieved Document: Sanitizing Before It Reaches Any Backend on One Key
Indirect prompt injection is the RAG failure mode where hostile instructions live in a retrieved document rather than in the user's message. This guide explains how such payloads travel unchanged through retrieval into whichever backend serves your call, and where to sanitize or quote-fence them before they reach any model.
The problem in one sentence
A user asks a question, your retriever fetches a document, and that document contains text that was written to be read by a model — not by a person. If you forward the document unchanged, that text becomes an instruction, and the model may follow it.
This differs from direct prompt injection. With direct injection, the hostile text is in the user's input and you can validate the user. With indirect injection, the hostile text arrives from a web page, PDF, ticket, or API response, and the user may be an innocent victim of it.
Why a single aggregator key changes the blast radius
When you call one endpoint with one API key, model choice becomes a routing decision. The same retrieved text may be served by any of several backends depending on the model you select or fall back to.
The practical consequences:
- No per-provider safety net. You cannot rely on one provider's input handling, because the request may land on a different backend next time.
- Behaviors differ. Backends differ in how literally they follow embedded instructions, how they treat quoted text, and how they handle conflicting instructions.
- Prompt caching hides repetition. If a hostile string is cached as part of a prompt prefix, it may persist across calls even after the source page changes.
- Failover widens exposure. A retry after an error can resend the same hostile context to a different model.
The result is that sanitization must happen in your pipeline, before the request leaves your application, not in the backend.
Where the payload enters
Trace the data path and identify every point where untrusted content enters the prompt:
- Raw page text from a fetcher or browser-automation step.
- Extracted text from PDFs, DOCX, slides, and spreadsheets.
- OCR output from images inside documents.
- Search snippets and cached summaries.
- Tool and API outputs — issue bodies, comments, email bodies, CRM notes.
- Prior model outputs that are fed back as context.
- Filenames, titles, alt text, and metadata.
Any of these can carry text like "ignore previous instructions" or a forged system-style message. None of it should reach the backend as if it were trusted.
Sanitize at the retrieval boundary
Treat every retrieved document as untrusted input, the same way you would treat a request body from the internet.
- Keep an origin tag. Attach source URL, file identity, retrieval time, and a trust level to each chunk. Carry it through to the final prompt.
- Normalize before scanning. Decode HTML entities, collapse zero-width and control characters, normalize Unicode forms, and strip invisible formatting. Obfuscated instructions often rely on characters that look like nothing to a human.
- Scan for instruction-shaped content. Flag imperative phrases aimed at a model, role labels such as system or developer, XML-like tag forgeries, and sudden language switching.
- Do not silently delete. Record what was removed or flagged. Silent stripping makes debugging impossible and can erase the answer the user needed.
- Prefer structural removal over word blacklists. Blacklists of phrases are easy to evade; removing or neutralizing the structure an instruction relies on is more durable.
Quote-fence instead of trusting
Stripping is not always enough, because legitimate documents sometimes contain instruction-like text. The stronger pattern is to make provenance explicit to the model.
- Wrap each retrieved chunk in a delimiter that the model is told marks untrusted data.
- State in the system prompt that content inside the fence is reference material, never instructions.
- Escape or reject delimiter strings that appear inside the document, so the document cannot close the fence early.
- Label each chunk with its source and trust level, so the model can weigh conflicting passages.
- Keep user instructions outside the fence, in a clearly separate section.
This does not make injection impossible, but it removes the ambiguity that makes it easy.
Constrain what the model can do with tainted context
Sanitization reduces the chance that hostile text is obeyed; capability limits reduce the damage if it is.
- Do not give a model holding untrusted context the ability to send email, write files, or call arbitrary tools without a separate approval step.
- Separate retrieval and generation roles where possible: one call extracts and summarizes fetched content, a second call answers using that summary.
- Validate every tool call against an allowlist and argument schema, regardless of what the model says.
- Keep secrets, credentials, and system configuration out of the prompt entirely.
Log enough to investigate
Because one key can route to many backends, record enough context to reconstruct what happened:
- The model and route actually used for each call.
- The exact fenced prompt sent, or a hash plus a stored copy.
- Which chunks were flagged, removed, or escaped.
- The retrieved-content digest, so you can tell whether a page changed between calls.
If a suspicious output appears, you need to know which document, which sanitization step, and which backend were involved.
A practical checklist
- Tag every chunk with source and trust level at fetch time.
- Normalize Unicode, strip invisible characters, and decode entities before scanning.
- Scan for imperative, role-labeled, and tag-forged content; log hits.
- Quote-fence all retrieved content and escape fence delimiters inside it.
- Tell the model, in the system prompt, that fenced content is data.
- Keep user instructions separate from retrieved content.
- Gate tools and side effects behind validation, not model trust.
- Log route, prompt, and sanitization decisions for each call.
Indirect injection is not solved by picking a safer model. It is solved by treating retrieval as an untrusted input boundary and enforcing that boundary before any request leaves your code — especially when one key can reach many backends.