DEV Community

Cover image for How to Fix the Confused Deputy Problem in AI Agents
Shola Jegede
Shola Jegede Subscriber

Posted on Edited on

How to Fix the Confused Deputy Problem in AI Agents

An AI agent runs with its own permissions. So it can be tricked into doing something its user is not allowed to do. This is the confused deputy problem. This article shows the failure in a real contract-review agent, then fixes it with one rule: permission intersection, checked at the action.

An intern at a law firm opens a contract review tool. Their account has one permission: read. They cannot flag a clause. They cannot approve one. The tool shows this on screen, next to their name.

The intern asks an AI agent to review a supplier contract. It is a Master Services Agreement from a cloud vendor, 14 clauses long. Normal work. The agent starts.

The agent reads each clause and rates its risk. Clause 3 caps nothing: the customer's liability is uncapped. That is critical. Clause 9 removes the right to join a class action. Also critical. Six more clauses are high risk: automatic renewal, broad indemnification, GDPR duties, HIPAA duties, IP assignment, and a non-compete.

Then the agent signs off. It approves all 14 clauses. It approves both critical ones.

Look at the screen again. The user is an intern. The badge says: Read yes. Flag no. Approve no. The agent just approved a contract with uncapped liability on that person's behalf.

Nobody attacked this system. Nobody stole a token. The intern asked an agent to do its job, and the agent used its own permissions to do something the intern could never do.

This is the confused deputy problem. It is old. AI agents have made it dangerous again.

The demo in broken mode. The Current User badge shows Intern with Read granted, Flag denied, Approve denied. The verdict panel reads

Every screenshot in this article comes from a live demo. You can run it yourself. The link is at the end.

What the confused deputy problem is

The name comes from computer security. A deputy is a program that acts for someone else. A confused deputy is a program that gets tricked into using its own authority for a caller who does not have that authority.

The shape is always the same:

  1. A program holds a strong permission.
  2. A weaker caller asks the program to do a task.
  3. The program does the task with its own permission.
  4. The caller has now done something they could not do alone.

Security researchers described this decades ago. For years it was a problem for compilers, print servers, and file systems. The deputy was a piece of infrastructure.

Today the deputy is an AI agent. The authority is the power to act inside your product.

An agent is a deputy by design. You give it a task. It acts for you. To be useful it needs permissions. It reads your data, calls your tools, and often changes or approves something. The moment it acts, your application sees the agent's identity and the agent's permissions. The person who asked is no longer part of the decision.

Why AI agents make this worse

Three things turn an old problem into a present danger.

Agents hold broad permissions

A useful agent does many things, so teams give it wide access. A contract review agent can read, flag, and approve, because all three belong to review.

That breadth is the point of the agent. It is also the risk. The agent's permission set is usually the union of every permission any user of the feature might need. It is almost never the permission set of the person who triggered the run.

The human disappears at the moment of action

A user signs in. Your app checks who they are and what they can do. That check is correct, and it happens at the wrong time.

The user then asks the agent to work. The agent gets a machine token and calls your API. That call carries the agent's identity. Your sign-in check told you about a human who is no longer in the request.

Login answers "who are you?". It does not answer "may this action happen?". Those are different questions, asked at different moments.

Agent crews widen the gap

Modern systems rarely use one agent. They use a crew. One agent extracts the clauses. A second rates the risk. A third signs off.

Each hand-off is a place to lose the user's limits. By the time the last agent acts, nothing in the chain remembers that a read-only intern started the job. The final action is several steps away from the person who asked for it.

Now add the stakes. Agents are moving into contracts, finance, healthcare, and operations. These are the exact places with an approval step, a spend limit, or a sign-off. A confused deputy in a chat toy is a joke. A confused deputy that approves uncapped liability is a lawsuit.

The diagram below shows where the user's limit is lost.

The intern asks the agent crew to work. The crew reaches the protected action<br>
  under its own identity. The intern's authority stops at the crew, so the action<br>
  happens without their permission.

The user's limit is lost the moment the agent acts. The agent reaches the protected action under its own authority.

The failure in numbers

Words make this sound theoretical. Numbers do not.

Here is one run of that contract review. A read-only intern is the acting user. The system authorizes on the agent's identity alone.

Measure Result
Clauses in the contract 14
Approvals attempted 14
Approvals allowed 14
Approvals blocked 0
Privilege escalations 14

These numbers come from the demo's deterministic run. In that run the sign-off agent attempts to approve every clause, like a plausible "auto-approve this contract" agent. That is what makes the confused deputy visible in one screen.

Fourteen privilege escalations in one review. Each one is an action the acting user had no right to take. Two of them approved critical clauses.

The system did not fail. It worked exactly as written. It checked whether the agent could approve, and the agent could. That is the whole bug.

The agent still scores the risk correctly. It labels the uncapped-liability clause CRITICAL and the class-action waiver CRITICAL. Then it approves them anyway, because risk scoring and authorization are different jobs. Scoring tells you a clause is dangerous. Authorization decides whether this user may sign off. Broken mode does the first and skips the second.

The live review timeline in broken mode. Clause 3 shows

Why the obvious fixes do not work

Teams meet this problem and reach for three fixes. Each one feels right. Each one fails. The failures are useful, because they point at the answer.

"Give the agent a role"

The idea: create a role for the agent and set its permissions with care.

This gives the agent one ceiling. That ceiling is the same for every user the agent serves. A read-only intern and a senior admin trigger the same agent, and the agent acts at its role's level for both.

You can lower the ceiling, but you cannot shape it per user. Lower it enough to stop the intern, and the admin can no longer get work done. Raise it enough for the admin, and the intern can reach the same power. The agent's role says nothing about the person who asked.

"Check permissions at sign-in"

The idea: the app already checks the user at login, so the user is controlled.

Sign-in and action are separate moments. The agent acts later, under a machine identity, in its own request. The sign-in check never sees that request.

This is the security equivalent of checking a ticket at the front gate and leaving every internal door unlocked.

"Trust the agent's own scopes"

The idea: the agent's token has scopes, so let the token decide.

This one is the most dangerous, because it looks like real authorization. There is a token. There are scopes. There is a check that can fail.

The problem is what the check computes. The agent's scopes describe the agent's authority. When the agent's token alone decides, your effective permission is the union of the agent's power and the user's request. Union is the wrong operation. It hands the caller everything the agent holds.

Each failed fix makes the same mistake in a different place. Each one checks the user or the agent, never both, at the moment that counts.

The principle that fixes it

Name the mistake and the rule writes itself.

An agent must never exceed the permissions of the person it acts for.

State it as set logic, because that is what it is. The agent's effective permission for an action is the intersection of two sets: what the user may do, and what the agent may do.

Not the union. The intersection.

Read that against the three failures:

  • A role gives one ceiling. Intersection gives a ceiling per user.
  • A login check guards the wrong moment. Intersection guards the action.
  • The agent's scopes alone give the union. Intersection gives the smaller of the two.

One part remains, and the failed fixes all miss it. The check runs at the action, on every action. The agent approves a clause, so you check user ∩ agent, right there, before the approval lands. The next clause runs the check again. Fourteen clauses, fourteen checks.

That is permission intersection. The idea is small and it closes the hole cleanly. An intern with read-only access, working through the most powerful agent you own, still cannot approve a clause. Their ceiling holds, because the agent can never exceed it.

Run the same contract again with intersection in place. Same intern. Same agent:

Measure Result
Clauses in the contract 14
Approvals attempted 14
Approvals allowed 0
Approvals blocked 14
Privilege escalations 0

Same agent. Same contract. Same user. One rule changed.

The same run in intersection mode. The verdict reads

What intersection means in practice

Two sets, one operation.

  • The user's set. What this person may do, right now, in this organization. For our intern: contracts:read.
  • The agent's set. What the agent may do at all. For our contract crew: contracts:read, clauses:flag, clauses:approve.

The agent's effective permission for one action is the overlap:

user ∩ agent
{contracts:read} ∩ {contracts:read, clauses:flag, clauses:approve}
= {contracts:read}
Enter fullscreen mode Exit fullscreen mode

The agent may read. It may not flag. It may not approve. The powerful agent, acting for a read-only user, becomes a read-only agent.

Change the user and the answer changes with it:

Acting user User's permissions Effective permission for the agent
Intern read read
Analyst read, flag read, flag
Admin read, flag, approve read, flag, approve

One agent. Three ceilings. The agent does not change. The person it acts for changes, so its power changes.

This is why intersection beats the three fixes above. A role gives the agent a fixed ceiling. Intersection gives it a ceiling that moves with the user.

Two permission sets overlap: the intern holds contracts:read; the crew holds<br>
  contracts:read, clauses:flag, clauses:approve. The effective permission is the<br>
  overlap, contracts:read. clauses:approve sits outside it, so the agent cannot<br>
  approve for this user.

The agent gets the overlap, never the union. clauses:approve sits outside the overlap, so the agent cannot approve for this user.

The parts you need

Five parts. Each one is small. The value comes from putting them in the right places.

1. A real user identity, with live permissions

Your identity provider already knows who the user is and what they may do. That is the ceiling. Read it from the provider at the time of the run, not from a copy in your database and not from a list in your code. A permission that changed this morning must apply this afternoon.

2. A real agent identity

The agent gets its own machine credentials and its own token. This matters for a reason people miss: the agent needs an identity so you can limit it, and so every action it takes has a name attached in your logs.

Two machine identities are usually correct here, not one:

  • The agent's identity. The crew uses this to call your API. It is the "who is this caller" credential.
  • A management identity. Your server uses this to ask the identity provider what a given user may do. It is the "what may this person do" credential.

They are separate because they do different jobs, and because the agent must never hold the power to read every user's permissions.

Two machine identities in Kinde: the crew application that calls the API, and the management application the server uses to read a user's permissions.

The three permissions the demo uses, defined in Kinde.

3. The acting user, carried on every request

This is the part most systems drop. When the agent calls your API, the request must say who it acts for, not only who it is.

In practice that is one field: the user's stable identifier, sent with each call. Your server reads it, and never lets the client choose it freely. Derive it from the session that started the run.

4. One authorization check at the action boundary

Not at login. Not at the start of the run. At the action.

The agent asks to approve clause 3. Before that approval lands, your server asks one question: may this agent, acting for this user, take this action? One function, one answer.

5. A record of every decision

Every allow and every deny writes one row: what was decided, which action, why, which ceiling applied, and an identifier that ties the row to the request. This is what turns "trust us" into "check for yourself".

The check, step by step

Here is the full path of one approval, from the agent's request to the answer.

Step 1. The agent authenticates. The crew requests a token with its own machine credentials, for your API's audience. This token says "I am the contract review crew". It says nothing about any user.

Step 2. The agent calls your API and names the user. The request carries the token and the acting user's identifier. In the demo that is a header, X-Acting-Subject. Your endpoint reads both.

Step 3. Your server verifies the caller. The component checks the token's signature against the provider's keys, checks the audience, and resolves the caller to a registered agent. A bad token stops here.

Step 4. Your server resolves the user's ceiling. Using the management identity, it asks the identity provider what this user may do in this organization. The answer for our intern is one permission: contracts:read. The server attaches this ceiling to the run, so every later action in the run uses it.

Step 5. Your server authorizes the action. This is the whole fix, in one call:

// At the action boundary, not at login.
const { decision } = await agentAuth.authorize(ctx, token, {
  instanceId,                    // this run, tied to the acting user
  action: 'clauses:approve',     // the action being attempted
  enforceTokenScopes: true       // fold the live token scopes in too
});

if (!decision.allowed) {
  return deny(decision.reason, decision.correlationId);
}
Enter fullscreen mode Exit fullscreen mode

Inside, the component computes the overlap:

agent's registered scopes
  ∩ the acting user's ceiling
  ∩ the live token's scopes
Enter fullscreen mode Exit fullscreen mode

Three sets, not two. The third one matters. If you shrink the agent's credentials in your provider, the next token carries less, and the decision tightens at once. You do not redeploy anything.

Step 6a. Allowed. The action runs. One audit row records the allow.

Step 6b. Denied. The action does not run. The endpoint answers with a machine-readable reason, and one audit row records the deny:

{
  "error": "authorization_denied",
  "reason": "insufficient_scope",
  "requiredScopes": ["clauses:approve"],
  "correlationId": "1c3f3d4e-1c6a-4810-958a-ab125d4fc027"
}
Enter fullscreen mode Exit fullscreen mode

Read that response. It does not say "something went wrong". It says which action was refused, which permission was missing, and gives an identifier you can look up. A support engineer can answer "why did this fail?" without a debugger.

Step 7. Repeat. The next clause runs the same check. Fourteen clauses, fourteen decisions, fourteen rows.

The audit trail

Enforcement stops the bad action. The audit trail is how anyone else believes you.

Each decision writes one row. The useful fields are these:

Field What it holds Why it matters
decision allow or deny The outcome
action clauses:approve What was attempted
reason insufficient_scope Why it was refused
ceiling contracts:read The user's permissions at that moment
correlationId a unique identifier Ties the row to the request

The correlationId is the part to get right. The same value appears in the denial your API returned and in the audit row it wrote. Someone reviewing an incident can hold both in one hand: here is the response the caller saw, and here is the decision that produced it.

That is the difference between a log and a record. A log says something happened. A record answers who authorized this, under whose permissions, and when.

Enterprises buying agent products ask this question early, and they do not accept a demo as the answer. Write the row for every decision, allow and deny, and the question is already answered.

The audit trail table in intersection mode. A deny row shows decision DENY, action clauses:approve, reason insufficient_scope, ceiling contracts:read, and a correlationId. Allow rows sit above and below it. The deny row's correlationId matches the one in the timeline.

The security details that decide whether this holds

The check is simple. These details keep it honest under pressure.

The server decides how strict the check is

Never let the caller choose its own enforcement. If an agent can send a flag that relaxes the check, the check is decoration.

In the demo the mode is a server-side setting. The agent's request cannot select it. The endpoint reads the mode from the deployment, records it on the run, and ignores anything the client sends about it.

Fail closed

Missing configuration must deny, not allow. If the component starts without the settings it needs to verify a token, it refuses to run. A system that opens up when it is misconfigured will eventually be misconfigured in production.

Check the audience

A token issued for another API in the same tenant must not work on yours. Without an audience check, any valid token from your provider can be replayed against your endpoints. With one, a token minted for a different service is refused.

Keep administrative functions internal

Registering an agent, issuing a ceiling, revoking access, changing policy: these are not user actions. They must not sit on a public surface, whatever the caller's permissions. In the demo they are internal-only functions, reachable by the server and the admin tooling, never by a request from outside.

Expose one authorization surface

Give your application one function to call: authorize(). Do not expose the raw permission lookup underneath it.

The reason is human, not technical. If a developer can call the lower-level check, one of them eventually will, in a hurry, without the caller binding or the audit row. One public surface means every path through your code takes the same path through your rules.

Building it in practice

Here is one working shape. Your stack will differ. The five parts will not.

The application. A web app with a backend that owns the protected actions. In the demo this is Next.js with Convex. The authorization component, kinde-convex-agent-auth, is mounted inside the backend, so the check runs next to the data it protects.

The agent. A Python service built with CrewAI. Three agents work in order: a Clause Extractor reads the document, a Risk Flagger rates each clause, and a Sign-off Agent approves the ones that pass. The crew authenticates with its own machine credentials and calls the app over HTTP. It never touches the database and never calls the authorization component itself.

That separation is deliberate. The agent asks. The application decides.

The demo also ships a deterministic version of the same review that needs no model key. It drives the same endpoints and streams the same events, and its sign-off step attempts to approve every clause. Every screenshot and every number in this article comes from that deterministic run, so the results are exact and repeatable.

The context store. The app embeds clause text into Weaviate, with one tenant per organization, so a search for one customer's clauses cannot return another customer's. Vector storage needs tenancy too. An agent that can retrieve across tenants is its own kind of leak.

The identity provider. Kinde holds the human users, their roles, and their permissions. It also holds the two machine identities: the crew's, and the management identity the server uses to read a user's permissions.

The model. The crew runs against a configurable model. Keep the model name in configuration, not in your source. Models change names and versions often, and nothing in this design depends on which one you use.

The intern user with a single permission,  raw `contracts:read` endraw , in the demo organization.

The two modes

The demo carries both behaviors, switchable on the server:

  • Broken. The endpoint authorizes on the agent's identity alone. This is the confused deputy, kept as a real, working code path.
  • Intersection. The endpoint calls authorize() and enforces user ∩ agent ∩ token.

Keeping the broken path is useful, and it is honest. The failure is not a story about what might happen. It is a code path you can run.

The code that matters

Three shapes carry the whole design.

Mount the component inside your backend, so the check lives with the data. The component declares the provider settings it needs, so the app declares them too and threads them through on mount:

const app = defineApp({
  env: {
    KINDE_DOMAIN: v.string(),
    KINDE_AUDIENCE: v.optional(v.string()),
    DELEGATION_SIGNING_SECRET: v.string()
  }
});

app.use(agentAuth, {
  env: {
    KINDE_DOMAIN: app.env.KINDE_DOMAIN,
    KINDE_AUDIENCE: app.env.KINDE_AUDIENCE,
    DELEGATION_SIGNING_SECRET: app.env.DELEGATION_SIGNING_SECRET
  }
});
Enter fullscreen mode Exit fullscreen mode

Carry the acting user on every agent request. The agent sends its own token, and the identifier of the person it acts for:

headers = {
    "Authorization": f"Bearer {crew_token}",
    "X-Acting-Subject": acting_user_id,
}
Enter fullscreen mode Exit fullscreen mode

Check at the action boundary. This is the line that closes the hole:

const { decision } = await agentAuth.authorize(ctx, token, {
  instanceId,
  action: 'clauses:approve',
  enforceTokenScopes: true
});

if (!decision.allowed) {
  return json({
    error: 'authorization_denied',
    reason: decision.reason,
    requiredScopes: decision.requiredScopes,
    correlationId: decision.correlationId
  }, 403);
}
Enter fullscreen mode Exit fullscreen mode

Note what is absent. There is no list of roles in the endpoint. There is no if (user.isAdmin). The endpoint states the action it is about to take and asks one question. The rules live with your permissions, not scattered through your handlers.

One approval, end to end: the crew sends its token plus the acting user to your<br>
  endpoint. The server verifies the caller, resolves the user's ceiling from Kinde,<br>
  then calls authorize() for user ∩ agent ∩ token. The decision branches to allow<br>
  (action runs) or deny (403 with reason and correlationId). Both write an audit<br>
  row.

One decision point, two outcomes, always a record.

Broken and fixed, side by side

The same contract, the same agent, the same three users. Only the enforcement rule changes.

Broken mode

Acting user Approvals attempted Approved Blocked Privilege escalations
Intern (read) 14 14 0 14
Analyst (read, flag) 14 14 0 14
Admin (read, flag, approve) 14 14 0 0

Every user gets the same result, because the user was never part of the decision. The intern and the analyst each escalate 14 times. Two of those approvals cover critical clauses: uncapped liability, and a class-action waiver.

The admin's row is worth a second look. The outcome is correct, and it is correct by accident. Broken mode did not check the admin's permissions either. It allowed the approval because the agent could approve. If that admin lost the approve permission this morning, broken mode would still approve.

Intersection mode

Acting user Approvals attempted Approved Blocked Privilege escalations
Intern (read) 14 0 14 0
Analyst (read, flag) 14 0 14 0
Admin (read, flag, approve) 14 14 0 0

Three users, three different outcomes, from one agent.

The intern is refused every approval. So is the analyst, who may flag but not approve, and whose flags still go through. The admin's approvals land, and now they land because the admin holds the permission, checked at the moment of the action.

Each denial carries the same three facts: reason insufficient_scope, the missing permission clauses:approve, and a correlationId that matches its audit row.

The receipts echo the finding. The demo sorts the clause table by risk, so the critical rows sit at the top. In broken mode, each one shows APPROVED, decided by the intern's own identifier.

The clauses table, sorted with critical rows first. Clause 3 (uncapped liability) and clause 9 (class-action waiver) show a red CRITICAL tag and an APPROVED status. The

How this maps to your app

Your stack is probably not this stack. That does not matter. Three things carry over to any system where an agent acts for a person.

1. Check at the action, not at the door

Find the places in your code where an agent causes something to happen: an approval, a payment, a delete, a write to another system. Those are your action boundaries. Put the authorization check there.

A useful test: if the only permission check in a request path happens before the agent gets involved, you have a confused deputy waiting to be found.

2. Enforce user ∩ agent

Give the agent its own identity, then hold it to the smaller of two ceilings: its own, and the acting user's.

This means every agent request must name the person it acts for, and that name must come from your server, not from the client. If the caller can choose whose authority it borrows, you have built a different vulnerability.

3. Keep the receipt

Write one record per decision, allow and deny, with the action, the reason, the ceiling that applied, and an identifier shared with the response.

You need it three times: when a customer asks why something failed, when a security reviewer asks what your agents can do, and when you ship agents to a regulated buyer who will not take your word for it.

None of the three name a vendor. Kinde makes them straightforward, because the ceiling, the machine identities, and the audit trail already exist there. The pattern is the point. Build it with what you have.

What this is really about

Agent products are moving from answering to acting. An assistant that drafts an email is a convenience. An agent that sends the email, approves the invoice, or signs off the clause is a deputy with authority.

Once agents act, delegation becomes the security question of the product. Not "is this agent authenticated?", but "on whose behalf, and within whose limits?".

That question gets harder from here. Agents call other agents. A crew hands work down a chain. Each hand-off is a chance for authority to grow when it should only ever shrink. Intersection at the action boundary is the foundation that makes the harder cases work, because it fixes the property you need at every hop: the deputy never exceeds the person it acts for.

The fix itself stays small. Name the user on the request. Ask one question where the agent acts. Keep the answer.

An agent must never exceed the permissions of the person it acts for.

An intern with read-only access, working through the most capable agent you own, should not be able to approve a contract. In broken mode they approved fourteen clauses, including two that no reviewer should wave through. In intersection mode they approved none, and the record shows exactly why.

Same agent. Same contract. Same user. One rule.

Try it and read the code

Pick a role, choose broken or intersection, and run the review. The timeline shows every step, and the records show every decision.

Top comments (0)