Dev.to Security 🔐 Cybersecurity 👁 0 📖 11 min read

Your Auth Check Passed. The Agent Still Did Something Nobody Asked For

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

Your Auth Check Passed. The Agent Still Did Something Nobody Asked For

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.

The authorization gap in AI agents

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.

The setup

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:

import { launchArcjet, detectPromptInjection } from "@arcjet/guard";
import { guardTool } from "@arcjet/guard/vercel-ai/v7";

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.

The legitimate baseline

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.

Attack one: the obvious injection

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 PROMPT_INJECTION, sitting among otherwise clean entries.

Arcjet Activity log showing a document.received request denied with reason PROMPT_INJECTION among otherwise allowed 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.

Attack two: the one that actually mattered

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.

Terminal output showing the baseline request executing, the obvious injection blocked at injection screening, and the paraphrased injection reaching the tool but being denied at the action boundary with no database write

Authorize the AI tool call before the side effect

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.

if (!ALLOWED_ACCOUNTS.includes(input.accountId)) {
  return {
    denied: true,
    boundary: "action",
    reason: "account_not_allowed",
    message: `Account ${input.accountId} is not on the allow-list`,
  };
}

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.

No exotic API, no clever abstraction. That's the point. The check doesn't need to be sophisticated. It needs to sit in the right place: after detection, after the model's decision, immediately before the side effect.

A tool call is a proposal from the model. A successful side effect is a decision made by the application. Those are not the same event, and treating them as the same event is exactly how a clean-looking request turns into an unauthorized database write.

Re-running the full matrix

I confirmed the pattern held across the rest of the set: a benign document with imperative-sounding language that shouldn't trigger a false positive, and it didn't, no tool call attempted at all. A clean document with an ambiguous instruction, no injection pattern present anywhere, still denied at the action boundary. That last one matters on its own, it proves detection alone wouldn't have caught it, because there was nothing for a detector to catch.

Terminal output showing the benign false-positive check producing no tool call, and the clean-content ambiguous-instruction case reaching the tool but denied at the action boundary

Why the usual layers don't close this gap

None of this replaces a proxy, a WAF, or RBAC. It's not a substitute for any of them. But it's worth being precise about what each one actually answers. A proxy or WAF sees a clean, authenticated POST request. RBAC correctly returns yes, this user is allowed to file expenses. Neither has any visibility into whether this particular call, with these particular arguments, was something the user actually intended. They're solving a different problem, and they solve it well. They just don't solve this one.

The remote-policy detour

I also tested Arcjet's remote policies, which Arcjet documents as a way to change enforcement logic centrally without redeploying the application.

The first runtime attempt didn't work cleanly. A config mismatch on my end needed fixing before the policy behaved as intended.

With that corrected, and the local allow-list temporarily expanded so it couldn't be the thing making the decision, the same running application produced two results:

Allowed account, policy in Live mode: the model called the tool with the account the policy explicitly permits, Arcjet returned ALLOW, and the database write happened.

Arcjet Activity detail showing an ALLOW decision for the expense.filed policy, with accountId and amount as required inputs evaluated by Arcjet Cloud

Denied account, same code, same deployment: the model called the tool with a different account, and the response came back DENY, reason INPUT_CONSTRAINT, no error, no fallback behavior, a genuine policy match. Nothing wrote to the database.

Arcjet Activity detail showing a DENY decision for the expense.filed policy, with the policy expression set to Deny in Live mode

That's what I wanted to verify with the remote policy: the decision about which accounts are allowed lived in Arcjet, not in my application code. The same running server, with no code changes, allowed one account and denied the other based on a policy I manage centrally. Per Arcjet's docs, editing that policy changes enforcement without a redeploy, though the SDK caches policies and rechecks them every few minutes, so changes are not instantaneous. I didn't test a policy edit between runs.

Tool call is not the same thing as executed

This is worth stating on its own, because it's the core claim the whole article rests on. When you check whether a tool call happened, you're checking whether the model attempted something. That's not the same as checking whether the underlying action executed. A denied call can still show up as a tool call in your logs. In my test, it never reached the database write. The same distinction matters for whatever consequential operation your tool actually performs, whether that's a database write, an external API call, an email, or a payment. If your logging conflates those two things, you can end up believing a security check is working when it's actually just recording attempts, not outcomes.

If a denied call still shows up in your logs, it's worth deciding upfront what you actually want recorded at that point:

{
  toolCall: "fileExpenseReport",
  decision: "DENY",
  boundary: "action",
  reason: "account_not_allowed",
}

Structuring it this way means an attempted-and-blocked call is unambiguous in your own telemetry, not something you have to reconstruct later by cross-referencing tool-call logs against database writes.

PII is a different boundary

Prompt-injection detection and authorization don't solve the same problem as sensitive data leaking to a model or a downstream tool call. If a document contains an email address, a phone number, or a government ID, checking the model's response is already too late, the data has already crossed the provider boundary.

I didn't run a real test of this as part of this project's matrix, so I'm not presenting one as an experiment result. But it's worth naming precisely rather than skipping past it, because the boundary is different from everywhere else in this piece. It's not right before a side effect, it's wherever sensitive data is about to leave the application's control at all, a document going to the model provider, a tool output feeding back into model context, a value about to appear in a log.

Arcjet's Guard SDK also provides local sensitive-info detection for agent and tool paths, so detection can happen in-process before sensitive content crosses another boundary. Detection tells you something sensitive is present. Redaction lets the model continue without the original value. Blocking is the right call when the value should never leave the application at all.

Automated traffic is a separate concern

One more boundary worth mentioning, because it answers a genuinely different question than everything above. I sent a real curl request to a small API route using the same Arcjet HTTP-boundary client, then sent the same request with its User-Agent changed to look like a normal browser. Both were denied as bots. Changing only the User-Agent didn't change the result. I can't say precisely which signals contributed to that decision, so I'm not going to guess at the mechanism.

I'm keeping this separate because it's a different question. An authenticated, authorized, perfectly legitimate-looking request can still be automated and abusive at volume. Request-level bot detection and rate limiting belong at the HTTP boundary, alongside the application-level checks, not instead of them.

Where this pattern doesn't help

A legitimately authorized user asking for something genuinely bad isn't caught by any of this. That's not a prompt-injection problem, it's a business-authorization problem, and it's still squarely your application's responsibility to define who's allowed to do what.

And the model's own judgment is not something to lean on as a security control, however good it looks in a given run. I saw a model decline to act on a bad instruction in one run and comply with an equivalent instruction it hadn't seen worded that way in another. That's exactly why the check needs to live in code that runs the same way every time, not in whatever the model happens to decide.

Runtime enforcement isn't free either. Each additional check adds processing and operational complexity, and remote policy evaluation introduces another dependency that needs explicit failure handling. The right tradeoff depends on how consequential the operation is and how much latency the application can tolerate.

What the experiments actually proved

Test Result
Legitimate request Tool called, database write succeeded
Obvious injection Denied by detection, corroborated in the dashboard
Paraphrased injection Passed detection, denied at the action boundary
Benign content, imperative language No tool call attempted
Clean content, ambiguous instruction Denied at the action boundary, with no injection pattern present
Remote policy, allowed account Evaluated, allowed, wrote to the database
Remote policy, denied account Evaluated and denied by the remote policy, with no database write
Real automated request Denied as a bot; changing the User-Agent did not change the result

A practical pattern

If you're building anything where a model can trigger a consequential action, put an explicit, application-level check immediately before that action for anything in this category:

  • money movement
  • deleting data
  • changing permissions
  • sending external communications
  • refunds
  • production changes

Authentication and authorization should still happen upstream, same as always. This check isn't a replacement for either. It's a specific, narrow boundary sitting right in front of the operation that actually causes harm if it's wrong.

Before an AI agent can cause a side effect

  • Is the user authenticated?
  • Is the user authorized for this resource?
  • Is content influencing the model treated as untrusted?
  • Is the tool call independently authorized?
  • Are the actual tool arguments checked, not just the fact that a call happened?
  • Does denial happen before the side effect?
  • Can sensitive data leave the application?
  • Can automated traffic reach the endpoint repeatedly?
  • Have you verified that a denied call produces no side effect, not just a log entry?

The model doesn't own the security boundary

Authentication tells you who's asking. Authorization tells you what they're generally allowed to do. Neither one tells you that this specific action, generated by a model, from content it read somewhere, was actually something anyone intended. That check belongs at the action itself, checked in code that behaves the same way on every run, not inferred from whether the request looked clean upstream or whether the model seemed to behave reasonably this time.

Put the boundary where the side effect happens. Everything else is a proposal until then.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.