The Agent Can Call the Tool. That Is Not the Same as Allowed.
An agent that holds a tool runs it with the whole application's credentials, not the current user's. That gap is the confused deputy problem, and it is how a support agent ends up refunding an order the user was never allowed to touch. The fix is a fail-closed authorization check on every side-effectful call.
Picture a support agent in production. A customer messages, the agent reads the thread, decides the right move is a refund, and calls its issue_refund tool. The refund goes through. Everyone is happy, until a week later when someone notices the agent refunded an order that belonged to a different customer entirely, because the message said "refund my last order" and the agent, helpfully, refunded the most recent order it could find.
The tool worked exactly as written. The agent was allowed to call it. That is the whole problem. The agent held a refund tool wired to your payment provider with a service account that can refund any order in the system, and nobody ever checked whether this user, in this conversation, was allowed to refund that order. The permission the agent used was the application's. The permission that mattered was the user's, and the two were never the same thing.
Being able to call a tool is not authorization
This is an old security bug wearing new clothes. It is called the confused deputy: a privileged process is tricked by a less privileged party into misusing its privilege. Your agent is the deputy. It holds broad credentials because it has to serve every user, and it takes instructions from whoever is in the conversation, including whatever text landed in that conversation from a document, a web page, or a customer who is fishing. When the instruction and the privilege meet with no check in between, the agent will do the thing.
The trap most teams fall into is conflating two questions that feel identical and are not. The first is "can the agent invoke this tool," which is about wiring: is the function registered, is the schema valid, does the model know it exists. The second is "is the current user permitted to perform this action on this resource," which is about authorization. Almost every agent framework answers the first question and silently treats it as an answer to the second. Registering a tool becomes, in effect, granting permission to everyone the agent ever talks to.
That works right up until the agent acts on behalf of users who do not all share the same rights. A read-only user and an admin drive the same agent. A customer who owns three orders and a customer who owns three thousand hit the same lookup_order tool. If the tool runs with the application's authority instead of the user's, every user effectively inherits the application's authority the moment they start a conversation. The blast radius of a single confused decision is your entire dataset.
Start by making the caller's identity part of the call
You cannot authorize an action if you do not know who is asking for it. So the first fix is unglamorous plumbing: carry the real acting user through the agent and into every tool, and never let a tool run without it.
The failure mode looks like this, and it is extremely common because it is the path of least resistance:
# The confused deputy. The tool runs with a shared service account
# that can touch any order, and it has no idea who is actually asking.
import stripe
stripe.api_key = SERVICE_ACCOUNT_KEY # can refund ANY order in the system
def issue_refund(order_id: str) -> dict:
order = db.get_order(order_id)
refund = stripe.Refund.create(payment_intent=order.payment_intent)
return {"refunded": refund.amount, "order": order_id}
Nothing in that function knows or cares who triggered it. The fix is to bind the acting user to the invocation and refuse to proceed without one. The user identity comes from your session or auth layer, not from anything the model produced, because the model is exactly the component you do not trust to report who it is working for.
from dataclasses import dataclass
@dataclass(frozen=True)
class Principal:
user_id: str
roles: frozenset[str]
org_id: str
# The agent loop is constructed with the authenticated principal from the
# session. The model never sees it, never sets it, and cannot change it.
def run_agent(principal: Principal, messages: list[dict]) -> str:
def issue_refund(order_id: str) -> dict:
# principal is captured from the enclosing scope, not passed by the model
return authorized_refund(principal, order_id)
...
The important move is that principal is captured from the trusted session, not handed in as a tool argument the model fills. If you let the model pass user_id as a parameter, you have just asked the least trustworthy component in the system to name its own permissions, and an injection will happily set it to whoever it likes. Identity flows in from the edge and rides along; it is never an input the agent chooses.
Put a fail-closed gate in front of every action
Now that every call knows who is asking, you can answer the real question before the side effect happens. The rule is simple and worth stating flatly: no side-effectful tool runs until a policy check says this principal may perform this action on this resource, and the default answer is no.
class Denied(Exception):
pass
def authorize(principal: Principal, action: str, resource: dict) -> None:
"""Deterministic policy. Raises Denied unless there is an explicit allow.
Default is deny: if no rule matches, the action does not happen."""
if action == "refund":
order = resource
# A customer may only refund their own orders.
if "customer" in principal.roles and order["owner_id"] == principal.user_id:
return
# A support agent may refund within their org, under a value cap.
if "support" in principal.roles and order["org_id"] == principal.org_id:
if order["amount"] <= 500_00: # cents
return
raise Denied(f"{principal.user_id} may not {action} on {resource.get('id')}")
def authorized_refund(principal: Principal, order_id: str) -> dict:
order = db.get_order(order_id) # load the real resource first
authorize(principal, "refund", order) # then check, before any effect
refund = stripe.Refund.create(payment_intent=order.payment_intent)
return {"refunded": refund.amount, "order": order_id}
Three properties make this hold up. It is deterministic: the model's opinion never enters the decision, so no amount of clever prompting changes the outcome. It is fail-closed: the function raises unless something explicitly permits the action, so a case nobody thought about denies rather than allows. And it checks against the loaded resource, not the model's claim about the resource, so an agent that invents an order_id still gets measured against that order's real owner and real amount.
Notice the shape of the answer. Authority is the intersection of two things: what the tool is capable of doing, and what the user is permitted to do. The refund tool can technically refund anything Stripe will let it. The support user can refund within their org under a cap. The action is allowed only where those overlap. A confused agent driven by a malicious message cannot widen that overlap, because the overlap is computed from the user's real grants, not from the conversation.
This is also the layer where prompt injection stops being catastrophic. Injection is the mechanism that confuses the deputy in the first place, and it deserves its own containment, which I wrote about in defending tool-using agents against prompt injection. But even a successful injection runs into this gate: it can make the agent try to refund someone else's order, and the gate denies it, because the injected instruction cannot grant the user permissions the user does not have.
Hand the tool a scoped credential, not the master key
The gate above stops unauthorized actions at your boundary. The last piece stops the tool from being able to overreach at all, by never handing it credentials broader than the current call needs. Instead of running every refund through one all-powerful service key, mint a short-lived, narrowly scoped credential per call, bounded to exactly the user and resource that were just authorized.
def scoped_stripe_client(principal: Principal, order: dict):
# Mint a restricted key bound to a single customer and a short TTL,
# instead of reusing the account-wide SERVICE_ACCOUNT_KEY everywhere.
key = stripe.ephemeral_key(
customer=order["stripe_customer_id"],
scopes=["refund:write"],
ttl_seconds=60,
)
return stripe.Client(api_key=key)
def authorized_refund(principal: Principal, order_id: str) -> dict:
order = db.get_order(order_id)
authorize(principal, "refund", order)
client = scoped_stripe_client(principal, order) # least privilege, per call
refund = client.Refund.create(payment_intent=order.payment_intent)
return {"refunded": refund.amount, "order": order_id}
Now even a bug in the gate is contained. If the policy check were wrong, the credential the tool holds still only works for that one customer for the next sixty seconds. Two independent controls have to fail together for real damage: the authorization decision and the credential scope. That is defense in depth, and it is the difference between a bug report and an incident.
The tradeoffs and where it goes wrong
None of this is free, and pretending otherwise is how it gets skipped.
The policy has to live somewhere real. Hand-rolled if branches like the example are fine for a handful of actions and become a swamp past a dozen. That is the point where you move the rules into a dedicated policy engine, an OPA or Cedar or a hosted authorization service, and keep your tools calling one authorize() that consults it. Do not scatter the checks across tool implementations; centralize them, because a check that lives in twenty places is a check that is missing from one.
Fail-closed will block legitimate work, and that is the design, not a defect. The failure you must avoid is the tempting fix where a denied action gets bounced back to the model as an error and the agent is left to "handle it," because a determined agent will simply try a different tool to accomplish the same end. A denial is a stop, surfaced to a human when it matters, not a hint for the model to route around.
And you have to log the decisions, not just the actions. Every allow and every deny, with the principal, the action, the resource, and the rule that fired, so that when someone asks why the agent refunded that order you can answer from a record instead of a guess. This pairs directly with tracing the run itself, which I covered in making an agent's cost and control flow visible: the trace tells you what the agent did, and the authorization log tells you why it was allowed to.
The takeaway
The mistake is treating "the agent has the tool" as if it means "the user is allowed." It does not, and the whole confused deputy problem lives in that gap. An agent runs with the application's authority but acts on behalf of users who each have far less, and unless you close the distance on every call, the weakest instruction in the conversation borrows the strongest permission in the system.
Closing it is three moves. Carry the authenticated user into every call and never let the model name itself. Put one deterministic, fail-closed gate in front of every side-effectful action, checked against the real resource and the user's real grants. Hand each tool a credential scoped to just that call, so a mistake in the gate is still contained. Do those, and a confused agent stays confused inside a box it cannot open.
If your agents are starting to take real actions on behalf of real users and you want that authorization layer built right before it becomes an incident report, this is exactly the work I help teams get in place. Book a consultation call and we can look at where your agents are acting with more authority than the people driving them.
Viral Ruparel
Generative AI consultant helping teams ship reliable LLM and agent systems in production.
Contact Viral about your AI project →Frequently Asked Questions
Is not prompt injection defense enough to stop this?+
No, they solve different halves of the problem. Injection defense tries to keep untrusted content from steering the agent in the first place. Authorization assumes the agent gets steered anyway, by an injection or by an honest user asking for something they are not entitled to, and limits what that steered agent can actually do. You want both, because injection defense is probabilistic and authorization is deterministic. The authorization check is the layer that holds when the model is wrong.
Why not just give each tool a narrow static scope and stop there?+
A static scope is set once, at tool registration, and it cannot know who is driving the agent right now. The same support agent serves a user who owns three orders and a user who owns three thousand. A static scope either lets both touch everything or blocks both from everything. The authority you actually want is dynamic: the intersection of what the tool can do and what the current user is permitted to do, evaluated per call. Static scoping is a useful floor, not the whole control.
Where should the authorization check live, in the tool or outside it?+
Outside the model and, ideally, outside each individual tool. Put a single gate between the agent deciding to act and the action running, so every tool goes through the same fail-closed check against one policy. If you scatter the logic inside each tool implementation, you will eventually add a tool that forgets to call it, and that one omission is your breach. One choke point is auditable and testable; twenty copies of the check are not.
Related Articles
Your Agent Cost $2 Yesterday and $40 Today and You Cannot See Why
An agent's cost is elastic and path-dependent, so the same task runs for two dollars one day and forty the next. Plain logs will not tell you which step looped. A distributed trace built on OpenTelemetry's GenAI conventions shows you exactly where the tokens went.
You Pay Full Price for the Same 8,000-Token Prompt on Every Agent Step
An agent resends the same enormous system prompt, tool list, and history on every step, and without prefix caching you pay full input price and full prefill latency each time. Structuring the prompt so the provider reuses its KV cache cuts both, and one wrong byte quietly turns it all off.
Your Agent Fans Out Faster Than Anything Downstream Can Take
An agent that spawns subtasks and fans out tool calls builds an unbounded work queue, hammers your rate limits, and burns the token budget before the run finishes. Backpressure, bounded concurrency, and circuit breakers keep the fan-out from taking down the systems it depends on.