Skip to content
allsrc.dev
Go back

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
=== 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'

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
)

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}")
    ...

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']

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'

When to use it

When NOT to use it

Trade-offs and failure modes of the pattern itself

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.”



Previous Post
The Two Layers That Decide Whether Your Agent Survives
Next Post
Pattern: The Tool Privilege Broker