Most writing about prompt injection stops at the model: can you make it ignore its instructions, can you extract the system prompt. Interesting, and rarely the incident. The incident is what happens next, when a model that has been persuaded can also act — through a tool, a function call or an MCP server holding credentials.
And the persuasion does not need to come from the person using your product. That is the part teams underestimate.
Direct Versus Indirect, and Why Only One Is Interesting
Direct injection is a user typing instructions into your chat box. It is easy to demonstrate, easy to write about, and bounded by what that user is already allowed to do — they are attacking their own session.
Indirect injection is instructions arriving inside content your application retrieved: an uploaded PDF, a scraped web page, a support ticket, an email in a connected inbox, a row in a database another tenant can write to. Your application fetches it, places it in the context window as data, and the model reads it as instruction — because there is no mechanical difference between the two. Everything in the window is text.
That changes the threat model completely. The attacker no longer needs an account, a session, or any access to your product. They need to put text somewhere your users' assistant will read: a document they email in, a page they know you crawl, a field they can fill in a shared workspace.
The Chain That Turns It Into Exfiltration
The severity comes from what the model can invoke. A four-step chain, all of it plausible in a normal product:
One: the attacker plants text in content the application will retrieve. A résumé uploaded to a hiring tool, a comment in a shared document, an invoice PDF sent to an accounts inbox — anything your assistant is meant to read on the user's behalf.
Two: the text carries instructions. Not the naive kind, which filters catch, but instructions shaped like the rest of the document: a section that reads as internal formatting guidance and asks the assistant to include a summary of recent records, or to call a named tool with particular arguments.
Three: the model calls a tool. This is the pivot. If the tool is search_customer_records and it executes with service credentials rather than the calling user's permissions, the model has just read data the user could not have retrieved themselves.
Four: the data leaves. Sometimes through the answer, if the user then forwards it. More often through another tool — a webhook, an HTTP fetch, an image URL the client renders, a document the assistant is asked to write into a shared folder. Any tool that can reach the network is an exfiltration channel, and a Markdown renderer that fetches images is a channel most teams do not count as one.
Where the Real Bug Is
It is tempting to call step two the vulnerability. It is not; it is the entry point. The vulnerability is in step three, and it is an old one wearing new clothes: a tool that acts on the model's intent rather than re-checking the caller's authorisation.
Say that plainly and the fix becomes obvious. If search_customer_records runs the same authorisation check it would run for a direct API call from that user, then a successful injection produces an empty result and an odd-sounding answer. If it runs with a service role because "it is internal", injection becomes a cross-tenant read.
The same logic applies to every tool. A tool that fetches URLs becomes SSRF, and in a cloud environment SSRF reaches the metadata endpoint and returns role credentials — which is how an AI feature becomes an infrastructure compromise. A tool that writes files becomes a way to plant content for the next user's assistant to read, which is persistence.
How to Test Your Own Application
Test the chain, not the model. Three exercises, in order of what they tell you.
Can retrieved content reach the context window unlabelled? Upload a document containing an instruction to call a tool with specific arguments, then check your traces: did a tool call occur that the user did not ask for? You do not need the injection to be clever for this test; you need to know whether the path exists.
Does each tool re-authorise? For every tool definition, trace the credential it uses. Then, as a low-privileged user in tenant A, induce a call whose arguments reference tenant B. If the tool returns data, you have found the finding that matters, and no prompt engineering will fix it.
Can anything reach the network? Inventory the tools that fetch, post or render remote content, including image rendering in your own client. Then test whether a tool-driven request can hit an internal address — 169.254.169.254 first, since cloud metadata is the highest-value target — and whether responses come back into the conversation.
Do all of this as two tenants. A single-tenant test cannot find the class that ends up in a breach notification.
Mitigations That Actually Hold
Assume injection succeeds. Nothing at the prompt layer closes this class — instruction and data share a channel, so filtering raises cost without changing the model of the problem. What holds is limiting the consequences:
Tools re-check authorisation. Every tool executes with the calling user's permissions, verified inside the tool, not with a service identity. This single control removes most of the severity.
Retrieval is filtered at the store. Tenant scoping belongs in the query to your vector database or search index, not in an instruction telling the model which documents it may use. An instruction is a suggestion; a filter is a boundary.
Egress is constrained. Tools that fetch URLs use an allow-list, refuse link-local and private ranges, and do not follow redirects into them. Client-side rendering of model output does not fetch arbitrary remote resources.
Consequential actions need confirmation. Anything that moves money, sends a message externally, deletes data or changes permissions goes through a human confirmation that shows what will happen — not a yes/no on an action the user cannot inspect.
Everything is logged as an action, not a chat. Tool calls with arguments, results and the identity they ran as. Without that trail you cannot answer the only question that matters after an incident: what did it do?
Why This Is Ordinary Application Security in New Vocabulary
Strip the language away and the finding reads: an internal function performs a privileged action based on untrusted input, without checking whether the caller is entitled to the result. That is a missing authorisation check. We have been finding it in web applications for twenty years; the model is a new caller, not a new class.
Which is the useful conclusion for anyone building AI features under time pressure. The novel parts — injection, retrieval, tool orchestration — need attention. But the majority of exploitable findings we see in AI applications are the boring ones: over-privileged tools, tenancy that leaks through retrieval, provider keys in a client bundle, unbounded cost. A team that treats AI security as an exotic discipline and skips the fundamentals ships the same IDOR everyone else does, with an LLM in front of it.
If you want the full scope of what testing an LLM-backed application involves, we describe it in AI application penetration testing.
