Short answer: trace data from a request source (HTTP body, form field, websocket message) forward through your code to see whether it lands in an LLM call's system-role message or gets concatenated into a system prompt string. If it resolves through a real import to a known LLM SDK and reaches that position unscoped, that's the vulnerability — independent of whether any specific attack phrase is present.
Why keyword matching doesn't work here
Scanning source code for phrases like "ignore previous instructions" catches text that's already sitting in a file — useful for checking untrusted content like an MCP tool description or a document you're about to feed to a RAG pipeline. It does not catch the underlying code vulnerability, because an attacker doesn't need to plant a famous phrase in your codebase — they need the code path to exist so that whatever they type at runtime lands somewhere the model treats as authoritative. The vulnerability is structural, not lexical.
What a traced finding looks like
A dataflow-based finding shows the actual path from an untrusted source, through however many function calls, to the exact LLM SDK call site it reaches:
HIGH Prompt injection via user input
PROVEN LLM01:2026 Prompt Injection
source src/chat.ts:8 request data `req.body.input`
flow src/chat.ts:13 passed as `systemPrompt`
sink src/chat.ts:10 openai.chat.completions.create — system role (OpenAI)
fix Keep system prompts static; pass user input as a user-role message.
Each line is checkable against the source file — that's what separates a traced finding from a keyword hit. Three things have to be true for this to count as a real (not heuristic) finding:
- The sink is import-resolved. The call has to trace through an actual
importto a known LLM SDK (OpenAI, Anthropic, the Vercel AI SDK, LangChain, Bedrock, etc.) — not just a function named something likechat()orcomplete(). - The source is untrusted. Request bodies, query params, form inputs, websocket messages — not a hardcoded string or a value that only ever comes from your own config.
- The position matters. Landing in a system-role message or being concatenated into a system-prompt string carries more trust than landing in a user-role message, which is the whole point — the model treats the two positions differently.
Trace it yourself
req.body, a Next.js route's parsed form data, a webhook payload.system-role message or a top-level system prompt is the dangerous position; a user-role message is the correct one.Doing this by hand across a real codebase is slow and easy to miss — a value can pass through several functions before it reaches the sink. SecureAI-Scan automates exactly this trace via AST analysis (ts-morph for TypeScript/JavaScript, tree-sitter for Python), only flagging calls that resolve through real imports:
npx secureai-scan@0.9.0 scan .
Check an MCP tool description or config instead? That's a related but different check — invisible Unicode and injection phrasing in untrusted text, not code dataflow.
How to detect MCP tool poisoning →Frequently asked questions
What is prompt injection?
Untrusted input reaching an LLM call in a position the model treats as an instruction — a system prompt or similarly elevated-trust position — instead of being clearly scoped as data. It's OWASP's LLM01:2026 category.
Does a "clean" scan mean the code is safe?
It means no traced dataflow from an untrusted source to a resolved LLM sink was found by the specific checks that ran. Static analysis is a filter, not proof of runtime safety — it can't see behavior that only emerges from how a model actually responds to a given input.
What's the fix?
Keep system prompts static; pass user-controlled input as a user-role message rather than concatenating it into the system prompt.