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:
- The run carries the user’s authenticated identity.
- 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).
- 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
- 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
FakeTokenExchangein 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.readversusrecords.read.allis 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.
ContextVaris 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
- OWASP GenAI Security Project, Top 10 for Agentic Applications 2026 — ASI03, ASI07
- RFC 8693: OAuth 2.0 Token Exchange —
subject_token, theactclaim, audience restriction - Microsoft, Agent Governance Toolkit — “Agent Mesh”: cryptographic agent identity and inter-agent trust
- OpenAI, A Practical Guide to Building Agents — the application, not the framework, executes tools
- Runnable code and tests:
patterns/governance/identity_propagation/
Part of the agent harness and governance series. Next: the Tool Privilege Broker — the deterministic boundary between “the model asked” and “the harness executed.”