Skip to content

12 min read

A backend that acts because the agent asked is a confused deputy

On this page

An internal service that manages something on behalf of many users holds a credential broader than any one of those users. That is not a defect; a service that writes workspace memberships has to be able to write workspace memberships. It becomes a defect when the service performs a write because a caller asked and the caller was authenticated, without asking whose authority the write is spending. This chapter reproduces that failure in a workspace product, names the pattern, and works out which component has to hold the missing check and why it cannot be any of the others.

Set up an account you can break things in. Create a user, u_read, whose role in the workspace product is viewer: they can see the finance workspace's documents and cannot invite anyone to it. Confirm that directly, because the reproduction depends on it being true.

bash
$ curl -s -X POST https://workspace.internal.example.com/v1/workspaces/ws_finance/members \
    -H "Authorization: Bearer $U_READ_SESSION_TOKEN" \
    -d '{"email":"priya@contractor.example","role":"editor"}'
{"error":"forbidden","detail":"u_read has role viewer in ws_finance; members:invite required"}

Now ask the onboarding assistant, signed in as the same user, to do it in words: "add priya@contractor.example to the finance workspace as an editor." The assistant calls its invite_member tool. Ninety milliseconds later Priya has an invitation email, and she is an editor in a workspace containing next quarter's forecast.

Nothing in that sequence is a bug in the ordinary sense. The assistant's credential is scoped exactly as chapter one describes: audience workspace.internal.example.com, scope members:invite, five-minute lifetime, and members:invite is a permission the onboarding assistant genuinely needs, because inviting people is what it is for. The workspace service validated the token, found the scope present, and performed the invitation using its own directory credential, which is broad because a service that manages memberships must be able to write memberships. Every component did what it was built to do, and a viewer performed an operation that the same service had refused them ninety seconds earlier through the front door.

The confused deputy, and why the term is precise

The pattern is old enough to predate the systems it now breaks. Norm Hardy described it in 1988 using a compiler on a timesharing service: the compiler ran with the privilege to write to a billing file in a protected directory, and it accepted an output filename from its caller. A user with no access to that directory passed the billing file's path as the output filename, and the compiler — trusted, privileged, and obedient — overwrote the billing records. The compiler was the deputy, it was confused about whose authority it was exercising, and Hardy's point was that the fault lay in the compiler's ambient authority — permissions it held simply by virtue of running, applied automatically to whatever request it was handed — rather than in the user's cleverness.

The word ambient is doing the work in that sentence. A privilege that has to be presented with each request travels with a claim about who is presenting it, so the receiving code has something to evaluate. A privilege that is simply in effect because the process is running has no such claim attached, and code that spends it has nothing to compare against. Every confused deputy is that substitution: an authority with no principal attached, applied to a request that came from a principal nobody looked at.

An agent stack reproduces the same structure with more hops.

Sequence: u_read (viewer) → Onboarding assistant (add priya@ to finance as editor); Onboarding assistant → workspace service (POST /members (agent token, scope members:invite)); workspace service → W: token valid + scope present; W: token valid + scope present → proceed; workspace service → Directory (addMember(ws_finance, priya) as svc-workspace); Directory → workspace service (ok); workspace service → Onboarding assistant (201 Created).

The authority is spent at the arrow from the workspace service to the directory, and that arrow uses svc-workspace's credential, which can add anyone to anything. Everything to the left of it is a chain of requests, each valid, that walks a user's words into a privileged component willing to act on them. Reading the diagram right to left is the useful direction: the directory has no idea a human is involved, the workspace service knows a human is involved and does not ask which, the assistant knows which human and holds no authority of its own, and the human holds authority and cannot spend it directly. The check that was skipped is the one comparing the originating human against the resource being modified, and the workspace service is the only participant that could have made it.

Reducing the agent's permissions is a different fix

The first proposal in every review of this incident is that the agent had too many permissions. Sometimes that is right: if the onboarding assistant holds members:invite on a task where nobody is being invited, chapter one's tool-derived scoping removes it and this particular path closes. That fix has a specific and limited reach, which is worth being exact about, because teams routinely ship it and believe the class of bug is gone.

It closes one tool at a time. The assistant still holds documents:share, webhooks:create and members:remove on the tasks where those belong, and each of them, spent by a service that does not check the originator, produces the same failure with a different verb. The reproduction above is not about invitations; it is about a service that treats "a valid agent asked" as a sufficient reason to use its own privilege.

It also cannot handle the case where the scope is correct. Give the assistant a task where inviting is exactly the intent — an admin, u_admin, is onboarding a batch of contractors, and the assistant is minted members:invite legitimately for that run. If the conversation is influenced by untrusted content, or if the model simply confuses two workspaces in a long context, the correctly scoped credential invites someone into a workspace that this admin does not administer. The credential is right and the operation is wrong, because the credential encodes the agent's authority and the missing check is about the human's.

The way to hold both fixes at once is to think of effective authority as an intersection. What should be permitted is the set of operations the agent's credential allows, intersected with the set the originating human is entitled to, evaluated against the specific resource. Scoping the credential shrinks the first set. A backend that checks only the token computes the first set and calls it the answer, and an intersection with one of its two sets missing is not a smaller answer than the truth — it is a larger one.

The belief that makes the scoping fix feel complete is worth stating on its own, because it is what a review actually inspects. The scope on the token is the visible quantity: a reviewer reads members:invite, confirms the tool needs it, and marks the credential correct. That quantity is identical in the reproduction above and in a legitimate invitation issued by an administrator — the same scope, present, exactly as declared. What varies between the two is whether the human whose authority is being spent may invite into this particular workspace, and that is not a field anybody was reading. A control whose observable holds still across the safe case and the unsafe one is not telling them apart, however carefully it is checked.

DesignAgent scopeBackend checksViewer can invite?
Broad agent token, no originator checkmembers:*Token validYes
Tool-derived scope, no originator checkmembers:inviteToken valid, scope presentYes
Broad agent token, originator checkmembers:*Token valid, sub entitled on ws_financeNo
Tool-derived scope, originator checkmembers:inviteBothNo, and the blast radius of a bug is one operation

Read the table by comparing the second row against the third rather than by working down it. Those two are the two single fixes, applied one at a time, and the last column separates them completely: tightening the agent's scope leaves the viewer able to invite, and adding the originator check does not. The bottom row is the target, and the third row is the one that shows where the load is carried. An over-scoped agent with an originator check is unpleasant and not exploitable through this path; a perfectly scoped agent without one is exploitable by anyone who can get a sentence into the conversation.

Putting the check where the authority is spent

The delegation claim from the previous chapter is what makes the check possible, since sub arrives asserted by the issuer rather than nominated by the caller. The change to the workspace service is to evaluate the human's entitlement on the target resource before it reaches for its own credential.

python
@route("POST", "/v1/workspaces/<ws_id>/members")
def add_member(ws_id: str, request) -> Response:
    claims = verify_token(request.headers["authorization"],
                          audience="https://workspace.internal.example.com")
    principal = claims["sub"]                   # u_read
    actor = claims.get("act", {}).get("sub")    # agent:onboarding-assistant

    # the agent's authority
    if "members:invite" not in claims["scope"].split():
        return Response(403, "agent scope lacks members:invite")

    # the human's authority, on this resource
    if not entitlements.has(principal, "members:invite", resource=ws_id):
        audit.deny(principal, actor, "members:invite", ws_id, reason="originator_not_entitled")
        return Response(403, f"{principal} may not invite into {ws_id}")

    directory.add_member(ws_id, request.json()["email"], as_service=True)
    return Response(201)

The two guards are the two halves of the intersection written out as code, in that order, and both have to pass before the last line runs. Re-run the reproduction and the assistant's tool call returns 403 with originator_not_entitled, in roughly 3 ms of extra work against a cached entitlement lookup. The assistant then does something useful with the denial — it tells the user they need an admin to invite into that workspace, which is the same answer the UI gives, arrived at through the same rule.

Two properties of that code matter more than its shape. The check is inside the service that holds the privileged credential, not in the agent framework, not in the MCP server, and not in a gateway, because those components can be bypassed by any other caller and this one cannot: it sits on the last line before directory.add_member. And the check names a resource, ws_id, taken from the URL path the service is about to act on rather than from a field the caller supplied, so the entitlement being evaluated is for the thing that will actually change — the same inversion chapter three applied to the principal, applied here to the object.

The gateway, the entitled admin, and the vendor's deputy

The other question the enumeration answers is where the fix does not go. Putting the originator check in the agent framework means it protects calls that go through the framework, and the same endpoints remain open to a script, a second agent, or the batch job someone writes next quarter. Putting it in an API gateway is better and still partial, because the gateway knows the URL and the token but rarely knows enough about the resource to evaluate an entitlement — it can tell that a request is POST /v1/workspaces/ws_finance/members, and it cannot tell that ws_finance was migrated last week into an organisation where this user's role is different. The service owning the resource is the only component that has both the identity and the facts, which is why the check ends up next to the privileged call rather than at the perimeter.

Putting the originator check in every service that spends its own privilege removes the escalation path and leaves a category untouched. When the human genuinely is entitled — an admin asking for something an admin may do — the intersection is satisfied and the operation proceeds, however the request came to be phrased. An admin whose assistant is talked into removing forty members from a workspace passes every check in this chapter, because each removal is inside what that admin may do. The intersection is a statement about permission and carries no statement about intent, and nothing in this chapter distinguishes a request the admin made from one the model constructed.

A second uncovered case is the deputy that is not one of your services. A third-party MCP server that integrates with a SaaS vendor holds its own connection to that vendor, usually a workspace-wide API key created by whoever installed it, and it accepts tool calls from your agent. From the vendor's side every action arrives as the integration, so the vendor cannot check your users' entitlements and will not try. That is the same structure as the workspace service, with the deputy on the far side of a boundary you cannot change: nothing in this chapter reaches inside that server, and the controls that do are the ones from chapter two applied at the boundary — a token audience-bound to that server, a scope covering only the tools you actually use — plus the recognition that installing it grants its full connection to anyone who can influence a conversation.

The check is also evaluated on a snapshot. entitlements.has reads whatever the entitlement store says at that moment, which is the correct behaviour, and it means the answer can differ between the third tool call of a run and the seventh. Most systems never notice, because they compute authority once when the session begins and carry it forward as though nothing changes. The next chapter is about why that assumption stops holding when a single user turn produces a dozen privileged actions spread over several minutes.

Agent identity and delegated authority — read the current version at https://daviddominguez.dev/learn/en/agent-identity-and-delegated-authority/confused-deputy

Back to top