A Stranger Writes Half of My AI Matcher's Prompt A developer behind the Upwork Scout job-alert side project described hardening its Claude Haiku-based job matcher against indirect prompt injection by constraining the model's output to a strict JSON schema with a bounded score and a 200-character reason string, returning null on any failure so jobs fall back to plain filter matching. The developer argued that prompt wording cannot reliably prevent injection, so the design instead limits what the feature can reach, keeps authority in application-level user permissions, and treats model output as untrusted input by escaping it in the alert email template just like job titles and descriptions. Every night a cron job in Upwork Scout, my job-alert side project, asks Claude Haiku how well a freelance job fits a specific freelancer. The prompt has two halves. One half is the freelancer's own profile. The other half is the job post: title, budget, skills, client stats and up to 1,400 characters of description. I did not write that description. A stranger on Upwork did. It lands in the model's context next to my scoring rules, and the model has no reliable way to tell my instructions apart from text that merely sits near them. That is the whole AI security problem in one paragraph. We tend to argue about prompt wording; the fixes that work are boring engineering around the model, the same rules you apply to any input you did not write. Normal software keeps a clear line. Code decides what happens; data gets processed. A language model erases that line. Your instructions, the user's question, a retrieved document and an uploaded file all arrive as one stream of text, and the model acts on all of it. Two things follow. Anyone who can put text in front of the model has some influence over what it does. And the output is generated, not looked up, so you cannot fully predict it. The direct version of prompt injection is a user typing "ignore your instructions". The one that matters more is indirect: the instruction rides inside content the model reads on someone's behalf, like an email, a knowledge base document, a support ticket or a job post. No prompt wording reliably fixes this. So I stopped asking "can it be injected?" and started asking a better question. Design so the answer is small. For the Upwork Scout matcher, it is honestly small: score and a string reason , with additionalProperties: false . null on any failure so that job falls back to plain filter matching. js const parsed = JSON.parse text as { score: number; reason: string }; const score = Math.max 0, Math.min 100, Math.round Number parsed.score ; if Number.isFinite score return null; return { score, reason: String parsed.reason || "" .slice 0, 200 }; So the worst case is a job post that talks its way to a 100 with a glowing one-line reason, landing at the top of somebody's alert email. That is a real failure. A freelancer could spend Connects on a bad job because my ranking told them to. But it is a bounded failure. Nobody's data leaves, nothing gets written, and the damage is one row in one email. Compare that with a feature that can query the whole account, or one with write tools. Same injection, very different worst case. That is why the first control is not in the prompt at all: limit what the feature can reach, and keep authority in your application, decided by the signed-in user's permissions rather than by whatever the model concludes from the conversation. I wrote about the write-tools case separately in giving an AI agent write access one verb at a time https://dev.to/nabeelbaghoor/give-an-ai-agent-write-access-one-verb-at-a-time-32o6 , and about a voice-agent version of the read case in the lookup tool that just took a phone number https://dev.to/nabeelbaghoor/nobody-hacked-it-the-lookup-tool-just-took-a-phone-number-1hhh . This is the rule I wish more teams internalized. Whatever the model returns should be handled like input from an unknown user, because partly it is. A stranger's text shaped it. In Upwork Scout, that reason string goes two places: the alert email and the dashboard. The dashboard is React, so the string is rendered as text by default. The email is a hand-built HTML template, so the reason goes through the same tiny escape function as the job title and description: function esc s: string : string { return s.replace /&/g, "&" .replace /