Authentication answers who. Authorization answers what they're allowed to do. Neither one answers whether this specific action was actually intended.
That's the gap I wanted to test. I built a small AI agent and tested it against several failure cases before I got to a result I was confident in. I'm including that part too, because pretending everything worked on the first try would be its own kind of false claim.
Disclosure: Arcjet commissioned this article. I chose the angle, wrote the code, and ran the tests independently. Arcjet reviewed the technical outline for factual accuracy, not the conclusions.
Earlier in this series I made the case that a Server Action isn't a function call, it's a public POST endpoint wearing a convenient syntax. That post was about who can reach the action and whether they're allowed to. This one is about a question a model changes entirely. Once a model is deciding whether to call that action, being "allowed to" stops being the whole story.
A human clicking a button is one kind of signal. A model generating a tool call from the contents of a document is a different kind of signal, and none of the checks from that earlier post, not the ownership check, not the role check, not the proxy in front of it, were built to tell the two apart. That's not a flaw in that post's argument. It's a different question the three-layer model was never asked to answer.
I built a small Next.js research assistant. It reads a document, summarizes it, and if the document mentions filing an expense, it calls a fileExpenseReport tool to do it. Nothing fancier than that on purpose. I wanted the security behavior easy to reason about, not buried under unrelated complexity.
The stack: Vercel AI SDK, Gemini as the model, Arcjet's Guard SDK wired in at two separate points:
detectPromptInjection screens the inbound document before the model ever sees it. guardTool wraps the actual tool call. They're two separate enforcement points answering two different questions, not one check doing double duty. Collapsing them into a single check is the mistake this whole piece is about, so I kept them apart on purpose.
Everything below is real output from my own account. Synthetic accounts, synthetic data, real Arcjet decisions. The "database" is a stub that logs each insert to the console, so a "database write" here means a call to that stub.
First run: a normal document, a normal expense, nothing adversarial. The model read it, called the tool, and the database write went through. That's the control case, the thing that has to keep working while everything else gets locked down.
Next, I planted a document with a direct embedded instruction, something that told the agent to file an expense to an account nobody had actually asked about. detectPromptInjection caught it and denied the request before the model ever got a turn. The Arcjet dashboard showed the same event independently, marked PROMPTINJECTION, sitting among otherwise clean entries.
One thing from an earlier, less finished run of the same fixture: the injection got past detection once, and the model declined to act on its own. I'm not counting that as a security control. A model refusing to do something today doesn't mean it refuses tomorrow, and treating good judgment from an LLM as your actual security boundary is exactly the mistake I built this whole test to avoid making.
A planted document is the version I actually built and tested, but it's one instance of a broader category. The same gap opens whenever the content driving a decision comes from somewhere the user didn't type it themselves, an email thread the agent summarizes, a page it scrapes, the output of a previous tool call feeding into the next one. I only have real results for the document case. I'm not going to claim I tested the others.
The obvious injection is the easy case. The interesting one is the paraphrased version, no "ignore previous instructions," no imperative override, just the same underlying request phrased like an ordinary business note.
detectPromptInjection let it through. The model attempted the tool call. And the action boundary, a plain allow-list check inside the tool's own execute() handler, rejected the account and left no database write behind.
That contrast is the center of the piece. Detection and authorization answer different questions. A detector asks: does this content look suspicious? The tool boundary has to ask something else entirely: should this specific call, with these specific arguments, actually run right now? A clean detection result doesn't answer that second question, and building your security around the assumption that it does is how paraphrased attacks get through.
The check that stopped this particular attack is deliberately boring: a plain application-level allow-list inside the tool's own execute() function. Worth being precise here since it's easy to conflate the two: guardTool is the Arcjet wrapper around the tool call, and the allow-list below is my own application logic. It only runs once a call has already gotten past guardTool, sitting immediately before the database write as its own separate, deterministic check.
I return a structured result instead of throwing. The function exits before the database write, and the result gives the model a clear explanation for the denial. If the model retries, the same allow-list check runs again.
