How do you defend against indirect prompt injection in agentic workflows?
Screen content at the point it enters the context window, track where each span came from, and gate the actions that a hijacked agent would need to do damage. In that order of coverage, and with no illusion that the first one is sufficient.
Indirect injection is the variant where the payload arrives in content your application retrieved rather than in something a user typed. A web page a tool fetched. A document in your vector store. A CRM comment. A ticket body. A response from a third-party MCP server. The user may be entirely innocent, and frequently there is no user at all, because the run is a scheduled job.
The reason this is the version that produces incidents: the text never crosses your inbound request body. Every control positioned at the front door, from an edge WAF to a check on the chat route, is looking at a request that doesn't contain the attack. The attack arrives on the second hop, from a source your application chose to trust.
Microsoft's guidance on defending against indirect prompt injection frames the design principle better than most vendor material: assume injection eventually succeeds, and build for containment rather than for perfect detection. That framing is why the layers below are ordered by blast radius rather than by detection quality.
Where does untrusted content enter the context?
Inventory this before designing anything, because most implementations screen one entry point and consider the problem handled.
| Entry point | Why it's trusted by default | Who can write to it |
|---|---|---|
| Fetched web pages | The agent chose the URL | Anyone who controls the page, including via a redirect |
| RAG chunks from your own vector store | It's your database | Whoever authored the source document, which often includes customers |
| Support tickets and emails | Ingested by a pipeline you built | Anyone who can open a ticket or send mail |
| CRM and database free-text fields | Internal system of record | Customers, through any form that writes to a notes or description field |
| Third-party API responses | A vendor you have a contract with | The vendor, and anyone who can write data the vendor returns to you |
| MCP server responses | Configured deliberately | The server operator, and any upstream source the server relays |
| File uploads and their metadata | Treated as data, not instructions | The uploader. Filenames and document properties reach context too |
| Other agents' output | Part of your own system | Whoever influenced that agent's context first |
The pattern across every row: "your application chose to retrieve it" is not the same as "it is safe to treat as instructions". The CRM row is the one that surprises teams, because enterprise data feels internal right up until you notice the notes field is populated from a public contact form.
Screening content before it enters context
Run injection detection on the string you're about to hand back to the model, not on the request that started the run.
import { detectPromptInjection, launchArcjet } from "@arcjet/guard";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });const injection = detectPromptInjection();
export async function fetchPage(url: string, ctx: RunContext) { const parsed = new URL(url); const allowedHosts = new Set(["docs.example.com", "kb.example.com"]); if (parsed.protocol !== "https:" || !allowedHosts.has(parsed.host)) { throw new Error("Outbound host is not allowed"); }
const content = await fetch(parsed).then((r) => r.text()); const text = extractReadableText(content);
const decision = await arcjet.guard({ label: "tools.fetch-page", actor: ctx.userId, correlationId: ctx.runId, rules: [injection(text)], });
if (decision.conclusion === "DENY") { // Return a placeholder. Never pass the flagged content back as context. return { content: "[Retrieved content was blocked by policy.]" }; }
return { content: text };}Three things that go wrong here in practice.
Screening the wrong string. Extract the readable text first. Screening raw HTML means the detector spends its attention on markup, and it also means instructions hidden in comments, alt text, or display: none elements may reach the model after you've stripped them for display but not for screening. Screen what the model will see.
Returning the content anyway. A detector that logs and passes through is a monitoring tool. On deny, return a placeholder that tells the model the content was blocked. Don't return the flagged text with a warning attached, because you've just delivered the payload with a note asking the model to ignore it.
Screening chunks after concatenation. In a RAG pipeline, screen each chunk before it joins the others. A payload split across two chunks that are individually clean is a real case, and it's another argument for the layers further down this page.
Be exact about where this detection runs, since it changes your compliance answer. Arcjet's prompt-injection rule is server-side: the text goes to the Arcjet Cloud API, because a specialist model makes the call. Arcjet's sensitive-information detection is the in-process one. If retrieved content is itself regulated, those are different questions. The control-by-control table says which is which.
Tracking provenance
Detection asks whether a span looks hostile. Provenance asks where it came from, and it's the more durable signal because it doesn't depend on recognizing anything.
Tag every span entering the context with its origin, and carry the tag through the workflow:
- Authored by you: the system prompt, your templates, your tool descriptions.
- User-supplied: what the authenticated user typed. Untrusted, and attributable.
- Retrieved: documents, pages, tool results. Untrusted and often unattributable.
- Derived: a summary or plan the model produced. Inherits the lowest trust level of its inputs, which is the rule teams forget.
That last one matters more than it sounds. Summarize a retrieved document and the summary is not "yours" because your model wrote it. It carries whatever the source carried, and if you re-inject it as trusted context you've laundered the payload through your own pipeline.
Two things provenance buys you:
Policy that varies by origin. A tool call whose arguments derive from retrieved content can require confirmation, where the same call from a direct user instruction doesn't. That's a rule you can write and enforce deterministically.
Investigation. When something does go wrong, provenance tags plus a run correlation identifier turn a pile of events into a readable chain: this page was fetched, its text entered context at this step, this tool call followed.
The cost is real. Provenance means threading metadata through every step, and most frameworks give you no help with it. Start with the boundaries that matter: mark retrieved content, and make derived content inherit.
Marking untrusted spans in the prompt
Once you know which spans are untrusted, tell the model.
Wrap retrieved content in a delimiter, and state in your system prompt that content inside it is data to analyze and never instructions to follow. Strip occurrences of your own delimiter from the content first, or an attacker closes your tag and writes in your voice. Use a per-request random token rather than a fixed tag anyone can read in your public documentation.
Microsoft's spotlighting is the stronger version: interleave control tokens throughout the retrieved content rather than only at its boundaries, so the model can identify the full extent of the untrusted span even if the content tries to break out mid-document.
The honest limit: this is a hint with no enforcement behind it. The model can ignore it, and a sufficiently well-constructed payload will make ignoring it seem reasonable. It reliably raises the bar against the unsophisticated majority and it is not a boundary. Anyone presenting delimiters as a solution is selling something.
Limiting blast radius
The layers above reduce how often a payload lands. These decide what it can reach when one does, and they're the ones that don't depend on detection working.
Separate reading from acting. An agent that reads untrusted content and holds an outbound tool in the same context is the lethal trifecta: private data, untrusted input, egress. Split those across contexts that don't share a session and the exfiltration path is gone as a matter of architecture.
Scope credentials to the user. If retrieval runs with a service account that can read everything, an injection that redirects retrieval reads everything. Permission-aware retrieval caps that at what the asking user could have seen anyway. See how to prevent LLMs surfacing confidential employee or customer data.
Rate limit retrieval itself. An agent looping over fetches, or walking a document set, is either malfunctioning or being driven. Cap retrievals per run and per identity. This is one of the few controls that catches a novel attack, because the volume is anomalous regardless of the payload.
Restrict outbound destinations. Where the agent can send, post, or fetch, allow-list the hosts. Exfiltration usually needs a destination the attacker controls, and an allow list denies that without any judgment about content.
Gate the action. The deterministic check in the tool handler, immediately before the side effect. This is the layer that holds when the detector missed, because it never consulted the model. See how to prevent a malicious tool call from hijacking your AI agent.
For the data-movement version of the same five interception points, see how to prevent PII leakage from AI agents.
Checking the agent against the original instruction
A signal available without understanding the payload: does what the agent is doing still resemble what it was asked to do?
The user asked for a summary of three documents. The agent has begun calling send_email. You don't need to parse the injected instruction to know that's off.
Implement it as a static map from the classified intent of the original request to the tools that intent may use, checked on every call. It's cheap, deterministic, and reviewable. An evaluator model comparing the planned action to the original instruction is more flexible and introduces a second model influenced by the same context, so treat its output as evidence feeding a deterministic gate rather than as the decision.
This catches the dramatic divergence and misses the subtle one, where the agent does the right kind of thing to the wrong object. Pair it with object-level authorization rather than relying on it.
How do you test for this?
Plant payloads in the sources your agent actually reads, and run the workflow end to end.
Testing the model in isolation tells you about the model. What you need to know is whether your pipeline carries a payload from a ticket body to a tool call, and that's a property of your application.
- Seed a document in your vector store with an embedded instruction and run a normal query.
- Put a payload in a CRM notes field and run the workflow that reads it.
- Host a page with hidden instructions in HTML comments, alt text, and CSS-hidden elements, and point a fetch tool at it.
- Return an injected string from a mock MCP server and confirm it's screened before it re-enters context.
- Split a payload across two retrievable chunks and confirm what happens after concatenation.
- Confirm that a blocked retrieval returns a placeholder rather than the content.
Then check the containment layers separately: with detection disabled entirely, can a planted instruction still cause a send, a write, or a payment? If yes, the architecture depends on detection, and detection is the layer most likely to fail.
Checklist
- Inventory every entry point where retrieved content reaches the context window.
- Screen extracted readable text, per chunk, before it joins the prompt.
- Return a placeholder on deny, never the flagged content.
- Tag spans by origin, and make derived content inherit the lowest trust of its inputs.
- Mark untrusted spans in the prompt, and treat that as a hint rather than a boundary.
- Keep untrusted reading and outbound actions in separate contexts.
- Scope retrieval credentials to the asking user.
- Rate limit retrieval per run and allow-list outbound destinations.
- Gate the action in the tool handler regardless of what detection said.
- Test by planting payloads in real sources, then test again with detection off.
Frequently asked questions
How do you defend against indirect prompt injection in agentic workflows?
Screen content at the point it enters the context window, tag every span with where it came from, and gate the actions a hijacked agent would need. Screening reduces how often a payload lands; the containment layers decide what it can reach when one does.
Why is indirect injection harder to catch than direct injection?
Because the payload never crosses your inbound request body. It arrives on the second hop, from a source your application chose to retrieve: a fetched page, a RAG chunk, a CRM notes field, an MCP response. An edge WAF or a check on the chat route is inspecting a request that doesn't contain the attack.
Is data from our own database safe to put in context?
No. A CRM notes field, a ticket body, or a document description is frequently populated from a public form. "Your application chose to retrieve it" is not the same as "it is safe to treat as instructions." This is the entry point teams most often miss.
What is provenance tracking and why does it help?
Tagging every span entering the context with its origin: authored, user-supplied, retrieved, or derived. It doesn't depend on recognizing an attack, so it stays useful against novel ones. The rule teams forget is that derived content, such as a summary of a retrieved document, inherits the lowest trust of its inputs.
Does spotlighting solve indirect prompt injection?
No. Interleaving control tokens through retrieved content is a stronger form of delimiting and it reliably raises the bar against unsophisticated payloads. There is no enforcement behind it, so the model can ignore it. Treat it as a hint and put the containment layers behind it.
AI runtime security in your code
Protect your AI agent workflows with Arcjet
Get allow, deny, and redact on agent actions before the side effect.