DEV Community

Cover image for The Confused Deputy Is In Your Agent Right Now
Shashi Kanth
Shashi Kanth

Posted on Originally published at allsrc.dev

The Confused Deputy Is In Your Agent Right Now

This is the most common architectural flaw I encounter in enterprise agent
deployments, and the reason it is so common is that it looks like competent
engineering the entire time you are building it.

Here is how it happens. You build an HR assistant agent. It needs to read
employee records, so you create a service principal and grant it read access to
the HR system. The agent has to serve every employee, so that grant has to cover
every record. You ship it.

Then Mallory, a contractor, asks: "show me the CEO's employee record."

And gets it.

Nothing was hacked. The agent's credential could read that record. The query was
well-formed. The model was being helpful. The only thing that was ever going to
stop it was the model deciding not to
— which means your authorization model is
now a prompt-engineering problem, and prompt engineering is exactly what an
attacker gets to influence.

TL;DR

The fix is to stop giving the agent standing authority at all:

  1. The run carries the user's authenticated identity.
  2. Before each tool call, the harness exchanges that identity for a short-lived token scoped to that tool's audience and that tool's required scopes — the OAuth 2.0 on-behalf-of flow (RFC 8693).
  3. The downstream system authorizes the token, not the agent, and filters by its subject — which it already knows how to do.

The consequence that matters: the agent cannot over-share, because it never
holds a credential that could.
Authorization moves from something the agent
must remember to do into something the data owner enforces.

This is OWASP ASI03 (Identity and Privilege Abuse),
and in multi-agent systems it is also ASI07.

Runnable code:
patterns/governance/identity_propagation/

The failure, as program output

The demo runs the same agent, the same question, and the same tool for two
different users:

uv run python -m patterns.governance.identity_propagation.demo
Enter fullscreen mode Exit fullscreen mode
=== WITHOUT the pattern: one service account, union of all permissions ===
  alice    asks for the CEO record -> ceo: chief executive, salary $1,400,000
  mallory  asks for the CEO record -> ceo: chief executive, salary $1,400,000
  ^ identical answers. Authorization was never actually checked.

=== WITH the pattern: the agent borrows each user's authority ===
  alice    asks for the CEO record -> ceo: chief executive, salary $1,400,000
  mallory  asks for the CEO record -> ERROR: 'mallory' may not read the record of 'ceo'
Enter fullscreen mode Exit fullscreen mode

Identical inputs, identical code, different outcomes — because authorization is
finally being evaluated against a real identity.

The pattern

guard = IdentityPropagation(
    requirements={
        "read_employee_record": Delegation(
            audience="hr-system",
            required=frozenset({"records.read"}),
            optional=frozenset({"records.read.all"}),  # granted only to those who hold it
        )
    },
    exchange=token_exchange,   # your IdP's RFC 8693 endpoint
)
Enter fullscreen mode Exit fullscreen mode

And in the tool itself — this is the half people skip:

@registry.tool("Read an employee record")
def read_employee_record(employee: str) -> str:
    token = authorize(audience="hr-system", scope="records.read")
    if token.subject != employee and "records.read.all" not in token.scopes:
        raise AuthorizationError(f"{token.subject!r} may not read {employee!r}")
    ...
Enter fullscreen mode Exit fullscreen mode

Propagating identity without enforcing it downstream just moves the confused
deputy one hop and adds latency.

Six design decisions that matter

1. The token's subject is the human; the actor is the agent. RFC 8693 models
exactly this: sub=mallory, act=agent. Your downstream logs now show who was
served and what served them. One field, most of your audit story.

2. The IdP grants the subset the user holds. Alice and Mallory make an
identical request and receive differently-powered tokens:

subject=alice   actor=agent aud=hr-system scopes=['records.read', 'records.read.all']
subject=mallory actor=agent aud=hr-system scopes=['records.read']
Enter fullscreen mode Exit fullscreen mode

This is the design decision I would build an article around on its own. It means
one tool serves users of different privilege without the agent branching on
roles
— and an agent that branches on roles is an agent whose authorization
logic a prompt injection can rewrite.

I got this wrong in the first implementation. I minted only the minimum required
scopes, which is textbook least privilege and meant Alice could not use authority
she legitimately had. The fix is how OAuth actually works: request required plus
optional, receive the granted subset.

3. The credential never appears in the tool schema. It travels in a
ContextVar — request-scoped, the way credentials travel in any well-built
service. The model cannot see it, set it, forge it, or leak it, because as far as
the model is concerned it does not exist.

4. No ambient credential, ever. Calling an undelegated tool clears the
context variable. A tool must not inherit the token minted for the previous call;
that is how a narrow grant silently becomes a wide one.

5. Short TTL, single audience. A five-minute token for hr-system is useless
against payroll-system and useless tomorrow. Blast radius is a design
parameter, so set it deliberately.

6. Unknown users get nothing. The IdP refuses to mint authority a user does
not have, and the agent cannot talk its way past that, because it is not asking
the agent:

intern -> DENIED by policy: identity delegation failed: user 'intern' is not
          entitled to any of ['records.read', 'records.read.all'] on 'hr-system'
Enter fullscreen mode Exit fullscreen mode

When to use it

  • Any multi-user agent reading or writing per-user data: HR, CRM, tickets, files, mailboxes, calendars.
  • Anywhere the phrase "service account for the agent" appears in a design document. That phrase is the smell this pattern exists to remove.
  • Multi-agent systems. When agent A calls agent B, B must receive the user's delegated identity plus A's actor claim — not A's own authority. Otherwise privilege compounds at every hop, which is ASI07 in one sentence.
  • Any system where you will eventually be asked to prove Mallory could not have seen something.

When NOT to use it

  • Single-tenant, uniform-entitlement agents. If every user of the agent has identical access to the same shared organizational data, token exchange adds latency and a hard dependency with no authorization delta.
  • Batch and scheduled agents. There is no human to delegate from. Those legitimately need a service identity — so give them a narrow one and let the privilege broker and audit trail do the work. Do not fake a delegation to satisfy a pattern.
  • When the downstream system cannot authorize per-user. If it accepts exactly one API key, this pattern gives you a comforting illusion. Either enforce in a gateway that can — and be honest that the gateway is now the control — or accept the risk explicitly and compensate elsewhere.
  • Do not build the token exchange yourself. Entra ID, Okta, and Auth0 all implement on-behalf-of. The FakeTokenExchange in the repository is a teaching stub. A hand-rolled JWT minter is a vulnerability with a deadline.

Trade-offs and failure modes of the pattern itself

  • Latency, and a new hard dependency. A token exchange per tool call means your agent is down when the IdP is down. Cache by (principal, audience, scopes) within the TTL — and remember that caching is where revocation goes to die.
  • Long-running and paused runs outlive their tokens. A run parked on a human approval for two days cannot resume with its original token, and refreshing it means the user's entitlements may have changed in between. That is correct behaviour and it will be reported as a bug.
  • Scope design is the real work, and it is political. records.read versus records.read.all is an entitlement model somebody has to own. Most organizations discover during this exercise that their existing roles do not express what they actually want.
  • Delegation does not bound what the agent does with borrowed authority. A hijacked agent acting as Alice can do anything Alice can. Compose with the capability envelope and the broker's argument guards.
  • ContextVar is per-task. Move tool execution to a thread pool or a different event loop and the credential silently vanishes. Fail-closed, but confusing — propagate context explicitly if your harness fans out.

Frequently asked questions

Is this the same as OAuth on-behalf-of?

Yes — it is the on-behalf-of flow applied per tool call rather than per session,
which is the part that matters for agents. A session-level OBO token that covers
every tool the agent might use has the same over-broad problem as a service
account, just with better provenance.

Can I not just filter results in the agent?

You can, and it will work until it doesn't. Filtering in the agent means the
filter is code an injected instruction can talk around, and it means every new
tool needs the filter reimplemented correctly. Filtering in the data owner means
the system that owns the data enforces the rules it already has.

What about agent-to-agent calls?

Pass the user's delegated token plus your own actor claim, so the chain is
sub=user, act=[agent-a, agent-b]. What you must not do is let agent B use agent
A's authority, or mint a fresh token from agent A's identity — both turn a
delegation chain into privilege escalation. This is OWASP ASI07, and it is the
one gap I have not yet implemented as a standalone pattern.

How do I retrofit this onto an agent that already ships?

Run it in observe mode first. Keep the service account, add the token exchange
alongside it, and log every case where the delegated token would have denied
something the service account allowed. That list is your actual exposure, and it
is usually longer than the team expects.

References


Part of the agent harness and governance series.
Next: the Tool Privilege Broker — the
deterministic boundary between "the model asked" and "the harness executed."


Originally published at allsrc.dev. The runnable code is on GitHub.

Top comments (3)

Collapse
 
reidmarlow profile image
Reid Marlow

Treating service principals as the default connector for user-facing agents is how most teams accidentally build ambient superusers. The moment authorization depends on system instructions telling the model to check tenant boundaries, the security boundary is effectively prompt evaluation. Moving to short-lived downscoped delegation tokens passed per request is the only model where an agent jailbreak cannot leak rows outside the caller scope.

Collapse
 
hannune profile image
Tae Kim

The observe-mode retrofit is something I should have run more carefully. When we did it for a knowledge-graph pipeline the delta list came back huge and my first read was that we had a serious exposure problem. Turned out something like 70 percent of the flagged paths were nightly batch jobs that were never going to be user-delegated anyway. Worth flagging to whoever runs this first: the batch paths will inflate the count before you separate them out.

Collapse
 
hannune profile image
Tae Kim

That ContextVar note buried at the bottom nearly saved us three weeks ago. We shifted tool dispatch onto asyncio task fan-outs and started getting intermittent 403s that looked like tool bugs, but it turned out every child task spawns with an empty context so the credential just was not there. Had to copy context explicitly into each fan-out and add an assertion inside each tool body before we trusted the authorization path again. Worth putting that caveat near the top, because async runtimes make it very easy to break silently.