« System Design · Track Overview
Design 03 — Agent Identity Across Three Hops and Two Organizations
"A relationship manager asks an agent a question. That agent asks a second agent, in another part of the Group, which calls core banking and moves money. Design the identity."
The question it turns on: can you keep the chain unforgeable and narrowing — and refuse, rather than degrade, at an organizational boundary?
Table of Contents
- 1. Constraints before components
- 2. Why this is hard
- 3. The trace, hop by hop
- 4. The token exchange, in detail
- 5. The chain: unforgeable, appending, bounded
- 6. Narrowing: what each hop gives up
- 7. The organizational boundary
- 8. Just-in-time credentials at the estate
- 9. Revocation, and the number nobody measures
- 10. Failure modes and blast radius
- 11. Evidence
- 12. What you build first
- 13. What changes at 10×
- 14. The questions you will be asked
1. Constraints before components
| Question | Assumed answer | What it eliminates |
|---|---|---|
| Who is the subject? | a human — always, at the head of the chain | autonomous agents with standing credentials |
| How many hops? | up to 3; more requires an exception | unbounded planner-spawns-planner designs |
| Cross-organization? | yes — Wholesale → Group Compliance | a single trust domain with shared secrets |
| Cross-cloud? | Azure primary, one AWS-hosted agent | anything relying on a single cloud's managed identity |
| Estate | core banking, over mTLS, with its own IAM | long-lived service accounts |
| Regulator | CBUAE + Internal Audit want attribution to a person | any design where the audit record names an agent |
| Latency | identity must fit 40 ms of the platform budget | a network call to an IdP per hop |
| Revocation | "stop this agent" must land in minutes | 8-hour access tokens |
The constraint that shapes everything: the audit record must name a person. That single requirement kills the simplest design (each agent has its own service principal) and forces delegation semantics through every hop.
2. Why this is hard
Four properties are individually easy and jointly awkward.
Attribution. Core banking must be able to say who asked. Not "the payments agent" — which human, and through which agents.
Least privilege. The agent must not receive the human's full privileges. A relationship manager can approve credit; the agent answering their question must not be able to.
Unforgeability. If the chain travels as a JSON field, any hop can rewrite it. The chain must be derived from verified credentials, which means each hop's token must attest to the previous one.
Boundedness. Three hops must not become thirty, and A→B→A must terminate.
The naive designs and why each fails:
| Design | Fails because |
|---|---|
| each agent has a service principal | the audit record names a robot; least privilege is per-agent, not per-task |
| the human's token is forwarded verbatim | the agent gets the human's full privileges; and the token's audience is wrong |
| the chain is a header | forgeable by any hop, including a compromised one |
| a bespoke internal JWT | you have invented an IdP, badly, and it has no revocation story |
The design that works is RFC 8693 token exchange with an actor claim, which is the standard's purpose and is exactly this problem.
3. The trace, hop by hop
┌───────────────────────────────────────────────────────────────────────┐
│ HOP 0 — the human, in Teams │
│ Entra ID, OIDC authorization code + PKCE │
│ id_token: sub=layla.almansouri, tid=<bank>, amr=[pwd,mfa] │
│ access_token: aud=ai-platform, scp=platform.use │
└────────────┬──────────────────────────────────────────────────────────┘
│ the CHANNEL validates and builds the Principal ONCE
┌────────────▼──────────────────────────────────────────────────────────┐
│ HOP 1 — the orchestrator agent (Wholesale, Azure) │
│ RFC 8693 exchange: │
│ subject_token = the human's access token │
│ actor_token = the agent's SPIFFE SVID │
│ requested scopes = kb.read, payments.read ← NARROWED │
│ result: aud=agent-mesh, sub=layla.almansouri, │
│ act={ sub: spiffe://bank/wholesale/orchestrator } │
└────────────┬──────────────────────────────────────────────────────────┘
│ mTLS, SPIFFE SVIDs on both ends
┌────────────▼──────────────────────────────────────────────────────────┐
│ HOP 2 — the payments investigator (Wholesale, Azure) │
│ exchange again; act chain now NESTS: │
│ act={ sub: .../investigator, act={ sub: .../orchestrator } } │
│ scopes: payments.read only ← NARROWED AGAIN │
└────────────┬──────────────────────────────────────────────────────────┘
│ ORGANIZATIONAL BOUNDARY — Wholesale → Group Compliance
│ federated trust; a DIFFERENT authorization server
┌────────────▼──────────────────────────────────────────────────────────┐
│ HOP 3 — the Group Compliance screening agent (Group, AWS) │
│ exchange at the Group AS, which validates the Wholesale token │
│ as an external issuer and re-issues in its own trust domain │
│ scopes: sanctions.screen only │
│ DEPTH LIMIT REACHED — this agent may not delegate further │
└────────────┬──────────────────────────────────────────────────────────┘
│ result returns; the investigator proposes an action
┌────────────▼──────────────────────────────────────────────────────────┐
│ THE ACTION GATEWAY │
│ JIT credential, minted per action: │
│ aud = core-banking-payments │
│ ttl = 60 s │
│ scope = payments.release:PMT-771 ← SINGLE RESOURCE │
│ act chain preserved; sub still the human │
└────────────┬──────────────────────────────────────────────────────────┘
▼
CORE BANKING (mTLS, its own IAM, sees a 60-second credential)
4. The token exchange, in detail
RFC 8693, grant_type=urn:ietf:params:oauth:grant-type:token-exchange:
POST /oauth2/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<the human's access token>
subject_token_type=urn:ietf:params:oauth:token-type:access_token
actor_token=<the agent's SVID / client assertion>
actor_token_type=urn:ietf:params:oauth:token-type:jwt
audience=agent-mesh
scope=payments.read kb.read
The response carries the delegation semantics in the act claim:
{ "sub": "layla.almansouri",
"aud": "agent-mesh",
"scp": "payments.read kb.read",
"act": { "sub": "spiffe://bank/wholesale/payments-investigator",
"act": { "sub": "spiffe://bank/wholesale/orchestrator" } },
"exp": 1773558000, "iat": 1773557700 }
Four things to point at:
sub stays the human. Through every hop. That is what makes the audit record name a person, and
it is the entire reason for using delegation rather than impersonation.
act nests, innermost = most recent actor. The chain is in the signed token. A hop cannot add
an actor it did not authenticate as, because the authorization server checks the actor_token.
Scopes are requested and granted. The AS grants the intersection of what the subject has, what the actor is permitted to request, and what was asked for. Asking for more than the subject holds does not escalate.
Short lifetimes. 5 minutes on the mesh; 60 seconds at the estate. Which is a revocation strategy (§9) as much as a compromise-window one.
Delegation vs impersonation is the distinction to name. Impersonation (may_act, no act
claim) makes the agent indistinguishable from the human downstream — convenient, and it destroys
attribution. Delegation keeps both identities visible. In a bank, always delegation.
5. The chain: unforgeable, appending, bounded
Three properties, three mechanisms.
Unforgeable — the chain lives in the act claim of a signed token, not in the request. A
receiving hop validates the signature, the issuer, the audience and the expiry, and derives the
chain from the claim. The rule to say out loud:
"The delegation chain is derived from a verified credential, never asserted in the request."
Appending — each exchange nests the previous act inside the new one. It never replaces. The
failure this prevents is the one no component test catches: hop three overwrites, the human
disappears, and every downstream record names an agent.
Bounded — two limits, checked at the authorization server, not politely at the caller:
- Depth ≤ 3. Counted from the
actnesting. A request for a fourth exchange is refused. - No cycles. If the requesting actor already appears in the chain, refuse — naming the chain, so the operator sees a loop rather than a recursion limit.
Enforcing both at the AS rather than in the agent framework is the design decision: an agent that forgets to check is a bug; an AS that forgets to check is a vulnerability, and only one of those is in your control.
6. Narrowing: what each hop gives up
| Hop | Holds | Gives up |
|---|---|---|
| human | everything their role permits: read, write, approve credit | — |
| orchestrator | kb.read, payments.read | every write, every approval |
| investigator | payments.read | the knowledge base |
| compliance agent | sanctions.screen | payments entirely |
| the action credential | payments.release:PMT-771 | every other payment |
Monotone narrowing — the scope set at hop n+1 is a subset of hop n. The AS enforces it; it is a single set-containment check and it removes an entire class of privilege-escalation bug.
The last row is the interesting one. The credential presented to core banking is scoped to one
resource instance, not to a capability. payments.release lets a compromised agent release every
payment it can enumerate. payments.release:PMT-771 lets it release the one the human asked about.
That is the difference between a bad day and an incident, and it costs one string in the scope.
7. The organizational boundary
Hop 3 crosses from Wholesale to Group Compliance: a different authorization server, possibly a different cloud, definitely a different team.
Federation, not shared secrets. The Group AS trusts the Wholesale AS as an external issuer — OIDC discovery, JWKS, key rotation. No shared signing key, because a shared key means a compromise of either side is a compromise of both.
Re-issuance, not pass-through. The Group AS validates the incoming token and issues its own, in
its own trust domain, with its own scopes. The act chain is preserved and extended, so
attribution survives the crossing.
The boundary is where you refuse, not where you degrade. This is the sentence that matters:
"If the Group AS is unavailable, the screening does not happen. The platform reports the screening as not performed and either escalates to a human or refuses the action — it does not proceed and record a screening it did not do. Degrading a control at an organizational boundary is how a control becomes a checkbox."
Compare that to the knowledge layer, where degradation is correct. The difference is what the component is: a quality contributor may degrade; a control may not. Same principle as the degradation ladder.
What crosses the boundary is also a data question. The screening request carries the counterparty and the payment reference — not the case notes, not the customer's transaction history. Cross-organizational calls get a minimal payload, and the classification travels with it.
8. Just-in-time credentials at the estate
The last hop is the one auditors care about most.
| Property | Value | Why |
|---|---|---|
| lifetime | 60 s | shorter than a useful replay window |
| audience | the specific core-banking endpoint | a stolen token is useless elsewhere |
| scope | payments.release:PMT-771 | one resource instance |
| binding | mTLS, sender-constrained (RFC 8705) | a bearer token that leaks is usable; a bound one is not |
| issued | per action, at the gateway | never held, never cached, never in an env var |
| carries | sub = the human, full act chain | core banking's own log names the person |
Sender-constrained is the upgrade worth naming. A bearer token in a log file is a credential. An mTLS-bound token requires the private key as well, so the log line alone is not enough. RFC 8705 (mTLS client-certificate-bound tokens) or DPoP (RFC 9449) are the two standards; mTLS is the natural fit when the mesh already does mTLS.
And no secrets in the agent. The agent's identity is a SPIFFE SVID delivered by the workload attestor, rotated on a short cycle, never written to disk. "Secret-less" is not a slogan here: the agent has no long-lived credential to steal, and everything it presents is derived at runtime from an attested workload identity.
9. Revocation, and the number nobody measures
"Stop this agent." How long until the last request it can serve?
$$T_{\text{revoke}} = T_{\text{decision propagation}} + T_{\text{token TTL}} + T_{\text{in-flight}}$$
With a 5-minute mesh token, a 30-second policy push and a 60-second request timeout, that is roughly 6.5 minutes worst case. Three ways to shorten it, in increasing cost:
- Shorter TTLs. Directly reduces the middle term; costs AS load.
- A revocation list at the resource server. Push revoked agent ids; check on validation. Costs a lookup on the hot path.
- An out-of-band kill switch. A separate, dumber, more-available channel whose only job is "stop". The action gateway checks it. This is the one that actually matters, because it does not depend on the token infrastructure being healthy.
"Time-to-revoke is measurable — revoke a test agent in production and time it — and the number is almost always worse than the team's estimate, because it is a sum of three things nobody adds up."
Raise it unprompted. It is the operational question a regulator eventually asks and almost nobody has instrumented.
10. Failure modes and blast radius
| Failure | Blast radius | Response |
|---|---|---|
| Entra ID down | new sessions only | existing tokens work until expiry; refuse new sessions |
| the AS down | new exchanges — i.e. new hops | cached tokens serve in-flight work; no new delegations |
| Group AS down | screening only | refuse or escalate; never proceed unscreened |
| JWKS rotation missed | everything, suddenly | cache keys with overlap; alarm on validation failure rate |
| a leaked mesh token | 5 minutes, one audience, narrowed scopes | mTLS binding makes it unusable alone |
| a compromised agent | its scopes, its chain position | it cannot escalate — narrowing is monotone and AS-enforced |
| clock skew | intermittent validation failures | NTP + a small leeway; alarm on iat in the future |
| a delegation cycle | one request | AS refuses, naming the chain |
The compromised-agent row is the design's payoff. An attacker who fully owns the investigator
gets payments.read, for five minutes, attributable to a named human and a named agent, with every
action still facing the gateway's dual-control and idempotency checks. That is a bounded incident
rather than an unbounded one, and it is bounded by construction rather than by detection.
11. Evidence
At every hop:
{ "trace_id": "...", "hop": 2, "issuer": "https://as.wholesale.bank",
"sub": "layla.almansouri",
"act_chain": ["orchestrator", "payments-investigator"],
"granted_scopes": ["payments.read"], "requested_scopes": ["payments.read", "kb.read"],
"audience": "agent-mesh", "ttl_s": 300, "auth_method": "token-exchange",
"peer_spiffe_id": "spiffe://bank/wholesale/orchestrator", "mtls_verified": true }
requested_scopes alongside granted_scopes is the field people omit and auditors want: it shows
the AS narrowing, which is the control actually working, rather than an agent politely asking for
little.
The question this evidence answers, in one query: "show me every action taken on behalf of Layla Almansouri last March, and which agents were in the chain."
12. What you build first
- The Principal, built once at the channel. Every later design decision depends on it.
- One token exchange, one hop. Delegation semantics,
actclaim, narrowing. Prove it end to end before adding hops. - Workload identity (SPIFFE/SVID) and mTLS. Removes the standing secrets, which is the largest single risk reduction available.
- Depth and cycle limits at the AS. Cheap now, and a vulnerability later if the agent framework is the only thing checking.
- JIT credentials at the gateway, audience-bound and resource-scoped.
- Federation to the second organization. Last, because it needs two teams and a trust agreement, and everything before it is unblocked.
- The kill switch and the time-to-revoke measurement. Then publish the number.
13. What changes at 10×
Thirty agents, six organizations, two clouds.
The AS becomes hot-path infrastructure. Every hop is an exchange. Cache aggressively by
(subject, actor, audience, scopes) for a fraction of the TTL, and make the AS multi-region — it
is now as critical as the model gateway.
The trust graph needs governance. Six organizations federating pairwise is fifteen trust relationships. Move to a hub trust domain (or SPIFFE federation with a single bundle endpoint) before the pairwise mesh becomes unmanageable.
Depth 3 starts to bind. Real workflows want four hops. The answer is not to raise the limit — it is an exception process: a named agent pair, a stated reason, an expiry, and a review. Raising the global limit converts a bounded system into an unbounded one for everybody.
Scope explosion. Per-resource scopes at scale become millions of strings. Move to a policy decision at the resource server (Cedar/OPA) with the token carrying attributes rather than enumerated resources — and keep the narrowing property by making the policy evaluate the chain.
Revocation gets harder and more important. With thirty agents, "stop this agent" happens monthly. The out-of-band kill switch stops being a nicety.
14. The questions you will be asked
"Why not just give each agent a service account?" — Because the audit record then names a robot, least privilege becomes per-agent instead of per-task, and there is a standing credential to steal. Every one of those is a finding.
"Isn't token exchange at every hop slow?" — It is one signed-JWT issuance, ~10 ms, cached for a fraction of the TTL. It fits the 40 ms identity allocation. And the alternative — forwarding the human's token — is not faster in any way that matters, it is just wrong.
"What if an agent lies about its chain?" — It cannot. The chain is in the act claim of a token
signed by the AS, and the AS only nests an actor it authenticated via the actor_token. A chain in
a request body would be forgeable; that is exactly why it is not in the request body.
"Three hops seems arbitrary." — It is a risk decision, not a technical limit. Each hop widens the blast radius of a compromise and adds a step whose failure compounds the task success rate. Three covers the workflows we have; a fourth needs a named exception with an expiry, which is how you keep the number meaningful.
"How fast can you turn an agent off?" — About six and a half minutes worst case today: 30 seconds of policy propagation, a 5-minute token TTL, and a 60-second in-flight timeout. There is an out-of-band kill switch at the action gateway that cuts it to under a minute for anything with a side effect, and I measure the number rather than estimating it.