Skip to content

Module 15: Agent Identity and Access

Listen to this page17:04
Read the transcript

1. The Question Neither Permissions Nor Policy Answers

Host: So Module 5 already told us what an agent is allowed to do, and we built a policy runtime that enforces it at the moment of action. You’d think that closes the loop, but there’s a question sitting underneath both of those that nobody’s answered yet.

Guest: Right, and the question is deceptively simple: on whose authority is this agent acting, and as whom? Permissions tell you what’s allowed in the abstract, policy enforcement tells you whether a specific call is allowed right now, but neither one tells you who the caller actually is. The easy answer everyone reaches for is that the agent just carries the user’s own token wherever it goes and acts as the user, full stop.

Host: Which sounds harmless until you say it out loud — if the agent is holding the user’s actual token, its blast radius is the user’s entire permission set, not just the slice of work it’s supposed to be doing. That’s the gap this module exists to close, and it’s where we’re headed.

2. A Token Is a Claim With Four Dimensions

Host: So let’s break that bearer credential open. You said a token is really a claim with four parts to it — walk me through those.

Guest: Subject, audience, scope, expiry. It’s claiming who this is about, who it’s meant for, what it’s allowed to do, and how long that’s true. A well-formed agent credential answers all four narrowly — this specific agent acting for this user, addressed to one named server, for this task’s tools, for a handful of minutes.

Host: And blanket delegation just… doesn’t bother answering three of those.

Guest: Right — audience collapses to ‘anything downstream will do,’ scope collapses to ‘everything the user can touch,’ expiry collapses to ‘the whole session.’ Only subject survives intact. And here’s the trap: you can have flawless capability enforcement checking exactly what that token is allowed to do, and it means nothing if nobody checked that the token was even addressed to the server enforcing it. That’s a correctly-guarded door the token was never supposed to walk through.

3. Two Branches: Forward or Exchange

Host: So given that trap, what actually happens architecturally when a request comes in? You said there are two branches — walk me through the one everybody builds first.

Guest: That’s the passthrough branch: the agent just forwards the user’s token unchanged to whatever downstream server it’s calling. The problem is structural, not incidental — that token’s audience names the original host, not the server now receiving it. The MCP spec explicitly prohibits this, because you’d be enforcing capability checks on a token that was never addressed to you in the first place.

Host: And the other branch is the exchange you keep gesturing at — so what does that server actually receive instead?

Guest: It receives a token that was minted specifically for it — this server, this scope, this agent — produced by exchanging the original token rather than relaying it. That’s the whole difference: instead of a document addressed to someone else that you’re hoping still applies, the server holds a credential that was written with its name on it. Now every check downstream — audience, scope, subject, all of it — has something real to actually verify.

4. Why On-Behalf-Of Broke

Host: So if exchange gets you a token minted for the specific server, why did anyone build it the other way in the first place? On-behalf-of has to have been solving something real.

Guest: It was, and it still is in a lot of systems. OBO preserves the user’s identity through a call chain — downstream services see the actual person, apply their real permissions, and the audit log attributes the action to a human being. In a request-response app where you wrote every hop yourself and the chain is short, that’s exactly the right behavior.

Guest: What breaks it is what agents add: the chain isn’t short anymore, a model is choosing the hops at runtime instead of a developer choosing them at compile time, and some of those hops land on third-party servers you don’t operate. OBO assumed you controlled or at least trusted every link. An agent’s whole value is deciding on the fly to call something you didn’t anticipate — that’s precisely the case OBO’s assumptions don’t cover.

5. The Mechanics: Exchange, the act Claim, and Resource Indicators

Host: So OBO is out because the assumptions don’t hold anymore. What actually replaces it — what does an agent get instead when it needs to call a downstream server?

Guest: A distinct grant type, RFC 8693 token exchange. The client hands over the user’s token as a subject_token, optionally an actor_token identifying the agent itself, and asks for something scoped to one specific resource. What comes back still has the user as subject, but the audience, scope, and lifetime are all narrowed to that one hop.

Host: Narrowed audience solves the blast-radius problem, but does it solve attribution? If something goes wrong at 3am, how do you tell an agent’s decision apart from the user actually clicking something?

Guest: That’s what the act claim is for. It records that delegation happened and names the actor that’s exercising the authority, so the log shows the user underneath and the agent on top, not one blurred identity. There’s a companion, may_act, sitting in the user’s own token, listing which actors are even allowed to act for them — checked at exchange time, not discovered after the fact when something’s already gone wrong.

Host: And the resource side — how does the authorization server know which server this narrowed token is even supposed to be good for?

Guest: That’s RFC 8707, resource indicators. The client states the target server’s canonical URI in the resource parameter, on both the authorization request and the token request, and that’s what lets the AS stamp an accurate aud. Then the server itself, acting as resource server, has to run five checks — signature, issuer, expiry, that aud actually names it, and that scope covers this exact tool — and skip any one of those five and the other four are decorative.

6. Server-Side Validation Is the Load-Bearing Half

Host: So walk through why it’s five checks and not, say, two. Why can’t a server just verify the signature and the audience and call it done?

Guest: Because each check closes a different attack, and they don’t overlap. Signature and issuer together tell you the token is real and came from an AS you trust — skip issuer and a token forged by some other authorization server you never approved sails through as long as the crypto is valid. Expiry closes the window on a token that leaked yesterday; aud makes sure a token minted for the calendar server can’t be replayed against the file server; and scope stops a token that’s valid for this exact server from being used to invoke a tool it was never authorized for.

Host: And that last one, scope covering the specific tool, that’s the one people skip, isn’t it?

Guest: Exactly, and it’s also where the discovery and registration machinery matters, because none of this works if a client can’t find the right AS in the first place. RFC 9728 gives servers a standard way to publish protected resource metadata so clients discover the authorization server instead of being hand-configured. Client registration is shifting too — Dynamic Client Registration is deprecated in 2026, with Client ID Metadata Documents as the draft-stage successor — but that’s plumbing around the edges. The five checks on the resource server are the part that actually decides what a stolen token can open.

7. Code: Validate Before You Trust

Host: So walk me through the actual validation code, because I imagine the order you check things in isn’t arbitrary. Where does a resource server start?

Guest: Cheap structural checks first, signature verification after, and critically, audience before scope. If you check scope before you’ve confirmed the token was even meant for this server, you’re doing meaningful policy work on a token that could be a real, validly signed credential for a completely different service.

Host: That’s the confused-deputy case again. But I want to flag something in the code — audience gets checked as part of the decode call itself, not after. Why does that matter?

Guest: Because the standard way this protection silently disappears is someone debugging locally turns off audience verification to get past an error, adds a manual claims comparison afterward to compensate, and then that setting never gets flipped back before it ships. Delegating the audience check to the library, in the same call that verifies the signature, means there’s no intermediate state where it’s quietly off. And the rejection reason — audience mismatch, bad scope, whichever — goes in the audit log, never the response. The caller gets a bare 401. Telling them which check failed hands them a map for probing the boundary.

8. The Support Agent Example

Host: Let’s put a real shape on this. Support agent, summarizing invoices for a customer, needs two tools on two different servers — read the invoice, read the contact record. Walk me through what actually happens at the credential level.

Guest: The user signs in, the host gets a token audienced to itself. Then two exchanges happen, one per downstream server. Billing gets a token with aud set to the billing server and scope invoice-read, five-minute lifetime. CRM gets its own token, aud set to the CRM server, scope contact-read, also five minutes, and both carry the agent’s identity in that act claim we covered earlier.

Host: And the blanket-delegation version of this same request would look like what, exactly?

Guest: One token, sent to both servers, carrying every scope the human support engineer holds — including issue-refund, which nobody asked for and this task never needed. So now say a prompt injection is sitting in the invoice PDF, and it talks the agent into trying to issue a refund. Under blanket delegation that succeeds, because the token can do it. Under agent-scoped credentials, the billing server checks the scope on the token in hand, finds no refund permission, and returns a 403 — the model got persuaded, but it never got a credential to make good on it. And the log line that results says agent acting for user, not just user, which is exactly the distinction that turns an incident into something you can actually explain.

9. How This Fails in Practice

Host: So we’ve seen the design that works. Where does this actually go wrong in practice, when teams think they’ve built it correctly but haven’t?

Guest: The first one is passthrough dressed up as an integration — you forward the user’s token straight to an MCP server you don’t control. Now that server holds a live credential it can replay against every other service that accepts it. The spec is explicit both directions: servers must not accept tokens not meant for them, clients must not send tokens issued for somewhere else. And you catch it by asserting on the audience claim in a test, because reading the code won’t show you a missing check.

Host: That last part sounds like it’s describing something that already happened somewhere, not a hypothetical.

Guest: It’s the most common one, actually. Someone hits an audience mismatch in staging, sets the audience check to false to unblock themselves, ships it, and it survives every review because the system is fully functional. There’s no runtime symptom for ‘accepts tokens meant for someone else’ — everything works, it’s just working with the door unlocked. Same pattern with scope: the exchange asks for whatever the subject token carries instead of computing the minimum, because computing the minimum is work, and the narrowing machinery sits there unused while every audit reads as compliant.

Host: And I’d guess lifetime and revocation have their own version of that same trap.

Guest: Right — someone mints an hour-long token to avoid refresh complexity, forgetting that short lifetime is the actual compensating control for a credential riding through model-directed control flow, so an hour-long token is just a stolen credential with an hour of runway. And then there’s the assumption that disabling the user’s account is the kill switch. It isn’t — already-issued agent tokens stay valid until they expire, and if the agent has its own client credentials it can keep minting new ones regardless. Revocation needs an identity for the agent itself to act on, not just the human’s.

10. What Narrowing Costs

Host: So this isn’t free. If I’m sold on exchange over forwarding, what am I actually signing up to pay for?

Guest: A round trip to the authorization server the first time an agent touches a given resource, which is latency forwarding doesn’t have — forwarding is just free and immediate, except it’s outright prohibited when the recipient’s an MCP server, so that comparison is a bit unfair. But the bigger cost is that your authorization server has to support RFC 8693, and someone has to actually decide the minimum scope per task. That’s design work, not a config flag.

Host: And the short lifetimes — minutes, you said earlier — that’s not just an inconvenience for long-running agents, right? That’s a failure mode you have to build for.

Guest: Exactly, and it’s the same shape of problem as retries from the distributed systems module — a long-running task has to expect expiry mid-flight and re-exchange, not treat it as an exception to route around by minting an hour-long token. Then there’s identity lifecycle: per-agent identities give you real attribution and let you revoke one agent without touching the others, but that means managing identities that scale with your agent count. A single shared identity is trivial to operate, sure — it just makes every audit log entry say the same name, which is worth nothing when you’re trying to figure out which agent did the damage.

11. Running It at Scale

Host: So if I’m running hundreds of these exchanges a day, the round trip itself becomes a tax. How do you keep that from strangling every tool call?

Guest: You cache the exchanged token for its lifetime, but the key has to be audience plus scope set, not just the user — key it by user alone and you’ll hand an agent a token for the wrong server. And validation stays local: signature checks against a cached JWKS are microseconds, whereas introspecting every token against the authorization server puts a network call in front of every single tool invocation.

Host: Which turns the AS into a dependency you can’t afford to have go down.

Guest: Right, it’s already on the critical path for first use of any resource, so its availability budget is now your agent’s availability budget — local validation is what keeps it off the path for every subsequent call, which is what makes that dependency tolerable. And that’s before you factor in scale: identity lifecycle has to become an automated operation past a handful of agents, scope names need a convention before they sprawl into unaudited duplicates, and multi-tenant hosting multiplies the audience space entirely — it’s the same isolation call the enterprise platform architecture makes, just applied to identity instead of infrastructure.

12. Proving It: The Lab and the Checklist

Host: So let’s put a number on all of this. The lab runs one token against a three-server, six-tool fleet and just measures what actually opens. What does it find?

Guest: Three results, and the middle one is the uncomfortable one. Fully scoped down to one audience and one scope, the token opens exactly one tool. Narrow only the audience and leave scope alone, it opens two — both tools on that server, refund included — and that configuration reads as compliant because you did narrow something.

Host: And the raw user token, just forwarded like the old default?

Guest: Zero. Nothing opens, because its audience names the identity provider and every server in the fleet rejects it outright. That’s not a win for passthrough, though — it’s dangerous for what it hands to whoever receives it, not for what it directly opens, and the lab is careful to say that rather than let the zero look like safety.

Host: That’s the whole episode in one table, honestly — audience checked server-side, scope narrowed deliberately, and don’t assume a protection you haven’t tested. On that note, thanks for walking through all of it.

Generated from this page by Claude Sonnet 5 on , spoken by Kokoro-82M running locally. Two synthetic voices, not a recorded conversation. Every claim is drawn from this page — where it differs from the text above, the text is correct.

Module 5 covers what an agent is permitted to do, and the Policy-Gated Tool Runtime enforces it. Neither answers a prior question: on whose authority, and as whom? The default answer is that the agent carries the user’s token and acts as the user everywhere — which is the easiest thing to build and makes the agent’s blast radius identical to the user’s entire permission set.

This module replaces that default with per-action, per-server credentials: short-lived tokens minted for a specific agent, narrowed to a specific audience and scope, that name both the user and the agent acting for them. The second half is the enforcement side — a remote MCP server deciding whether a caller may invoke a tool, where the answer can never be “because the caller said so.”

Authentication asks who is calling. Authorization asks what may they do. Agent systems add a third question that neither covers: who is acting, on whose behalf, and for how long?

A useful frame: a token is a claim about a subject, addressed to an audience, valid for a scope, until an expiry. Blanket delegation collapses three of those four — the audience becomes “anything downstream”, the scope becomes “everything the user has”, and the expiry becomes “the user’s session”. What is left is a bearer credential of enormous blast radius travelling through a system whose control flow is decided by a model.

Narrowing each of those independently is the entire discipline:

Dimension Blanket delegation Agent-scoped credential
Subject The user The user, with the agent named as actor
Audience Whatever accepts it One named server
Scope The user’s full permission set The tools this task needs
Lifetime The session Minutes

Identity is not the same as permission

A capability check answers “is read_invoice allowed here?” An identity check answers “who is asking, and is this token even addressed to me?” A system with excellent capability policy and no audience validation will happily enforce that policy against a token minted for somebody else — which is a correctly-implemented gate on the wrong door.

The left branch is what most systems do first and what the MCP specification explicitly prohibits: forward the user’s token unchanged. The server receives a token whose audience names the host, not itself, and must reject it. The right branch exchanges that token for one minted for this server, this scope, and this agent — after which every check on the server side has something real to verify.

On-behalf-of, and why it became the default. OBO exists for good reasons: it preserves the user’s identity through a call chain, so downstream systems apply the user’s own permissions and audit trails attribute actions to a real person. In a request-response application where the chain is short and every hop is code you wrote, that is often exactly right. Agents break the assumptions underneath it. The chain is no longer short, the hops are chosen by a model at runtime, and some of them are third-party servers you do not operate.

Token exchange (RFC 8693). The mechanism for narrowing is a distinct grant type, urn:ietf:params:oauth:grant-type:token-exchange. The client presents a subject_token — the user’s credential — optionally an actor_token identifying the agent, and asks for a token scoped to a particular resource. What comes back is a new token whose subject is still the user but whose audience, scope, and lifetime are all narrower.

The act claim. RFC 8693 defines act (actor) to “express that delegation has occurred and identify the acting party to whom authority has been delegated.” This is the claim that makes an agent’s actions attributable as the agent while still recording the user underneath. Without it, an audit log cannot distinguish a user who clicked a button from an agent that decided to act on their behalf at 3am. The companion may_act claim states, in the user’s own token, which actors are permitted to act for them — a constraint checked at exchange time rather than at use time.

Resource indicators (RFC 8707). Audience narrowing needs the client to say what the token is for. MCP clients MUST implement RFC 8707: the resource parameter is included in both the authorization request and the token request, and it identifies the MCP server the token will be used with, by that server’s canonical URI. This is the input that lets the authorization server stamp a correct aud.

Server-side validation is the load-bearing half. MCP servers, acting as OAuth 2.1 resource servers, MUST validate that access tokens were issued specifically for them as the intended audience, and MUST NOT accept or transit any other tokens. The validation is not one check but five, and skipping any of them makes the rest decorative:

  1. Signature verifies against the issuer’s keys.
  2. Issuer is one this server trusts.
  3. Token has not expired.
  4. aud names this server.
  5. scope covers the specific tool being invoked.

Discovery. Servers MUST implement OAuth 2.0 Protected Resource Metadata (RFC 9728) so clients can find the right authorization server rather than guessing or being configured by hand.

Client registration is moving. Dynamic Client Registration is deprecated as of 2026-07-28, with Client ID Metadata Documents as the migration path — the client hosts a metadata document at a URL which becomes its client_id, removing the registration round-trip when client and server have no prior relationship. Note the maturity honestly: CIMD is an IETF draft — MCP 2026-07-28 cites revision -00, and the draft has since reached -02 (2026-07-06) without changing that mechanism — and MCP says clients and authorization servers SHOULD support it, not MUST. New work should plan for it; nothing is broken today by DCR.

The question to ask in the design review

Not “do we use OAuth?” — everyone says yes. Ask: if I steal one token from this system, what can I do with it, where, and for how long? A system using OAuth end to end can still answer “anything the user can do, on any service, until they log out.” That answer is the finding, and it is independent of which libraries are on the slide.

Validation on the resource server, with each check separated so a failure says which invariant broke. The order matters: cheap structural checks before signature verification, and audience before scope, so a misaddressed token never reaches policy evaluation.

from dataclasses import dataclass
import jwt # PyJWT
class TokenRejected(Exception):
"""Carries which invariant failed, for the audit log — never for the client."""
@dataclass(frozen=True)
class AgentPrincipal:
"""Who is calling, and on whose behalf."""
subject: str # the user
actor: str | None # the agent, from the `act` claim
scopes: frozenset[str]
def validate(token: str, *, this_server: str, trusted_issuer: str, jwks) -> AgentPrincipal:
try:
claims = jwt.decode(
token,
key=jwks,
algorithms=["RS256"],
# PyJWT enforces aud, iss, and exp itself when given them. Doing it
# here rather than after decode matters: a hand-rolled `claims["aud"]
# == this_server` check after a decode that was told to skip audience
# verification is the most common way this ends up unenforced.
audience=this_server,
issuer=trusted_issuer,
options={"require": ["exp", "aud", "iss", "sub"]},
)
except jwt.InvalidAudienceError as error:
# The confused-deputy case: a real, signed, unexpired token — for someone else.
raise TokenRejected("audience does not name this server") from error
except jwt.PyJWTError as error:
raise TokenRejected(f"token invalid: {type(error).__name__}") from error
actor = claims.get("act", {}).get("sub")
return AgentPrincipal(
subject=claims["sub"],
actor=actor,
scopes=frozenset(claims.get("scope", "").split()),
)
def authorize_tool(principal: AgentPrincipal, tool_name: str, required: str) -> None:
"""Scope check, separate from identity, so the audit log distinguishes the two."""
if required not in principal.scopes:
raise TokenRejected(
f"scope {required!r} required for {tool_name!r}; token carries {sorted(principal.scopes)}"
)

Two details worth defending. TokenRejected carries a specific reason for the log, not for the response — telling a caller which check failed is a probing oracle; the wire gets a bare 401 or 403. And the audience check is delegated to the JWT library rather than performed after decoding, because a decode configured with verify_aud=False followed by a manual comparison is the standard way this protection is silently removed during debugging and never restored.

A support agent summarising a customer’s recent invoices. The user signs in through the identity provider; the host receives a token whose audience is the host. The agent then needs two tools on two different MCP servers — billing.read_invoice and crm.read_contact.

Under blanket delegation, one token goes to both servers, carrying every scope the support engineer has, including billing.issue_refund. Under agent-scoped credentials, the host performs two exchanges: one token with aud=https://billing.internal/mcp and scope=invoice:read, another with aud=https://crm.internal/mcp and scope=contact:read, both expiring in five minutes, both naming the agent in act.

The difference shows up the moment something goes wrong. A prompt injection in an invoice PDF that convinces the agent to issue a refund produces a 403 from the billing server, because the token in hand has no refund scope — the model can be talked into requesting anything, but it cannot be talked into holding a credential it was never issued. And the audit entry reads agent X acting for user Y rather than user Y, which is the difference between an incident you can explain and one you cannot.

Token passthrough — the confused deputy

The host forwards the user’s token to an MCP server it does not control. That server now holds a credential usable against every other service that accepts it, and can replay it. The specification forbids this in both directions: servers MUST NOT accept tokens not intended for them, and clients MUST NOT send tokens other than ones issued by that server’s authorization server. Detect it by asserting on aud in a test, not by reading code.

Audience verification disabled during debugging

Someone hits an audience mismatch in staging, sets verify_aud=False to unblock themselves, and the flag survives review because everything passes. There is no runtime symptom — the system is fully functional and completely unprotected. This is why the check belongs in library configuration where its absence is visible, not in a conditional.

Scope inflation at exchange time

The exchange requests whatever scopes the subject token carries instead of the minimum the task needs, usually because computing the minimum is work. The narrowing machinery is present, the narrowing is not, and every audit reads as compliant.

Long-lived agent tokens

Short lifetimes are the compensating control for a credential travelling through model-directed control flow. A token minted for an hour “to avoid refresh complexity” is a stolen credential with an hour of runway.

Revocation that only works for humans

Disabling the user account is treated as the kill switch. It is not: already-issued agent tokens remain valid until expiry, and if the agent has its own client credentials it may still mint new ones. Revocation needs an agent-level identity to act on.

Agent-scoped tokens vs. on-behalf-of everywhere

Exchanging per server and per task bounds the blast radius, makes actions attributable to the agent, and allows revoking an agent without disabling a person. It costs a round trip to the authorization server on first use of each resource, requires the authorization server to support RFC 8693, and forces you to decide what the minimum scope actually is — which is real design work, not configuration. Forwarding the user’s token is free and immediate, and is prohibited outright when the recipient is an MCP server.

Short token lifetimes vs. refresh complexity

Lifetimes measured in minutes shrink the window a leaked credential is useful for, and are the main defence when you cannot enumerate everywhere a token has travelled. They also mean long-running agent tasks must handle expiry mid-task — which is a real failure mode to design for (Module 2 on retries applies directly), not a reason to extend the lifetime to hours.

An identity per agent vs. one shared service identity

Per-agent identities give precise attribution and per-agent revocation, at the cost of identity lifecycle management that grows with the number of agents. A single shared identity is trivial to operate and makes every audit entry say the same thing, which is worth roughly nothing during an incident.

  • Validate audience server-side, always. A token is not evidence of anything until you have confirmed it was addressed to you. MCP servers MUST reject tokens that do not include them in the audience claim.
  • Never accept or transit a token minted for someone else, even to be helpful. Passing it on makes you the deputy.
  • Keep tokens out of URLs. Access tokens MUST NOT appear in query strings — they land in access logs, proxy logs, and browser history.
  • Validate scope per tool invocation, not per connection. Under a stateless protocol (Module 6) there is no connection to attach a decision to; every request carries its own authorization and deserves its own check.
  • Treat the model’s request as untrusted input. A tool call is a request to act, produced by a system that can be argued with. Identity and scope checks sit between that request and execution — the same seam Module 5 puts the permission check on.
  • Return opaque failures. 401 and 403 without detail; the specific invariant goes to the audit log.
  • Prefer CIMD over Dynamic Client Registration for new work, while remembering it is a draft and MCP only says SHOULD.

An identity system is not made secure by the presence of JWTs

Signed tokens prove a token was issued by someone with the signing key. They prove nothing about who it was issued for until audience is checked, nothing about what it permits until scope is checked, and nothing about currency until expiry is checked. “We use JWTs” answers none of the three questions.

  • The exchange is a round trip on the critical path the first time an agent touches a resource. Cache the resulting token for its (short) lifetime, keyed by audience and scope set — a cache keyed by user alone will hand back a token for the wrong server.
  • Validation should be local. Signature verification against cached JWKS is microseconds; introspecting every token against the authorization server puts a network call in front of every tool invocation and makes the AS a hard dependency of every request.
  • Refresh ahead of expiry, not on failure. Discovering expiry via a 401 mid-task costs a failed call plus a retry, and the retry has to be safe to make.
  • Cache JWKS with a bounded TTL and handle rotation — a key rotation while your cache is warm looks exactly like a forged token.
  • Identity lifecycle grows with agent count. Ten agents can be registered by hand; a thousand need provisioning, rotation, and de-provisioning as automated operations, or stale identities accumulate and nobody dares delete them.
  • Scope definitions are the real scaling limit. Each new tool needs a scope, and each new agent needs a defensible minimum set. Without a naming convention this becomes an unauditable sprawl of near-duplicate permissions faster than the tool count suggests.
  • The authorization server becomes a critical dependency. Exchange is on the path to first use of every resource; its availability budget is now part of your agent’s. Local validation keeps it off the path for every call, which is what makes the dependency tolerable.
  • Multi-tenant hosting multiplies the audience space. One server per tenant, or one server with tenant-scoped tokens, is the same isolation decision the Enterprise MCP Platform page argues, applied to identity.

An agent needs to call three internal services on a user's behalf. Walk me through the credentials.

Not one credential. The user authenticates once and the host holds a user token, but each service gets its own token obtained by exchange (RFC 8693), with resource naming that service’s canonical URI so the audience is correct, and scope narrowed to what the task needs rather than what the user is entitled to. Each carries an act claim naming the agent, so the audit trail distinguishes the agent from the human. Lifetimes in minutes, refreshed ahead of expiry. The property I am buying: stealing the token used for service A gives you nothing against B or C — with a single forwarded user token it gives you everything the user has, everywhere.

Your MCP server receives a valid, correctly-signed, unexpired token. Do you run the tool?

Not yet — that describes a token, not a token for me. I check that aud names this server and reject if not; the specification requires it, and the reason is the confused deputy problem. A signed, unexpired token issued for a different resource is exactly what an attacker replaying a stolen credential presents. Then I check scope covers the specific tool being invoked, since audience gets you in the door and says nothing about which room. Both checks happen per request, because the 2026-07-28 protocol is stateless and there is no session to have decided this earlier.

When is on-behalf-of the right answer?

When the call chain is short, entirely inside your trust boundary, and the downstream service’s own authorization is the control you are relying on — a request-response app calling its own backend, where preserving user identity is exactly what you want and no model is choosing hops. It stops being right when the chain is chosen at runtime, when it crosses to a party you do not operate, or when the actions are taken by something other than a person. All three are true of agents, which is why the default inverts.

How would you revoke a misbehaving agent at 3am?

It depends on whether the agent has an identity, which is the real answer. If it does, I disable that identity at the authorization server: no new exchanges succeed, and already-issued tokens expire within minutes because they are short-lived — the short lifetime is what makes revocation tractable without a distributed revocation list. If the agent has been acting as the user throughout, my only lever is disabling the user, which takes a person offline to stop a program and still leaves outstanding tokens valid. I would treat that as the finding rather than the incident.

The companion lab, Agent Identity Broker, implements everything above: RFC 8693 exchange, the five server-side checks, and a harness that walks one token against a three-server, six-tool fleet to measure what it actually opens.

Credential Tools opened, out of 6
Agent-scoped: one audience, one scope 1
Audience narrowed, scope not narrowed 2 — both tools on that server, refund included
The raw user token, forwarded 0 — no server accepts it

The middle row is the one worth sitting with: narrowing the audience without narrowing the scope gets you half the protection and reads as compliant. The bottom row is the unflattering one — a forwarded user token opens nothing here, because its audience names the identity provider. Passthrough is dangerous for what it hands to whoever receives it, not for what it opens directly.

The lab is production-shaped: the mechanics are complete and tested, the identity provider is simulated. Its README lists each omission separately rather than as “simulated IdP”, because that phrase hides how much is missing.

Prove your server rejects a token minted for someone else

Take any MCP server you run. Mint two structurally valid, correctly-signed, unexpired tokens that differ only in aud — one naming your server, one naming a different resource. Assert that the first is accepted and the second is rejected with a 401, and that the rejection reason reaches your audit log but not the response body. If the second is accepted, you have found a confused deputy. If you cannot easily mint the second, that difficulty is itself worth noting: a protection you cannot test is a protection you are assuming.

Before an agent touches a production system

  • Every token the agent presents names one audience, and that audience is checked server-side
  • Scope is the minimum the task needs, decided deliberately rather than inherited from the user
  • The agent is identifiable in the token (act) and in the audit log, separately from the user
  • Token lifetimes are minutes, and expiry mid-task is a handled path
  • The agent can be revoked without disabling a human
  • Audience verification is enforced by library configuration, not a conditional
  • Failures return 401/403 with the specific reason logged, not returned
Version Date Change
1.2.0 2026-10-08 Re-verified: specification and RFCs unchanged; CIMD citation corrected from -00 to the current -02, noting MCP still cites -00.
1.1.0 2026-08-14 The companion lab exists now; replaced the “not yet built” note with the lab and the blast-radius numbers it measures.
1.0.0 2026-08-14 Initial publication, written against the MCP 2026-07-28 authorization spec and the OAuth RFCs it builds on. Suggested by Shantanu Lodh.