« 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

QuestionAssumed answerWhat it eliminates
Who is the subject?a human — always, at the head of the chainautonomous agents with standing credentials
How many hops?up to 3; more requires an exceptionunbounded planner-spawns-planner designs
Cross-organization?yes — Wholesale → Group Compliancea single trust domain with shared secrets
Cross-cloud?Azure primary, one AWS-hosted agentanything relying on a single cloud's managed identity
Estatecore banking, over mTLS, with its own IAMlong-lived service accounts
RegulatorCBUAE + Internal Audit want attribution to a personany design where the audit record names an agent
Latencyidentity must fit 40 ms of the platform budgeta network call to an IdP per hop
Revocation"stop this agent" must land in minutes8-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:

DesignFails because
each agent has a service principalthe audit record names a robot; least privilege is per-agent, not per-task
the human's token is forwarded verbatimthe agent gets the human's full privileges; and the token's audience is wrong
the chain is a headerforgeable by any hop, including a compromised one
a bespoke internal JWTyou 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 act nesting. 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

HopHoldsGives up
humaneverything their role permits: read, write, approve credit
orchestratorkb.read, payments.readevery write, every approval
investigatorpayments.readthe knowledge base
compliance agentsanctions.screenpayments entirely
the action credentialpayments.release:PMT-771every 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.

PropertyValueWhy
lifetime60 sshorter than a useful replay window
audiencethe specific core-banking endpointa stolen token is useless elsewhere
scopepayments.release:PMT-771one resource instance
bindingmTLS, sender-constrained (RFC 8705)a bearer token that leaks is usable; a bound one is not
issuedper action, at the gatewaynever held, never cached, never in an env var
carriessub = the human, full act chaincore 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:

  1. Shorter TTLs. Directly reduces the middle term; costs AS load.
  2. A revocation list at the resource server. Push revoked agent ids; check on validation. Costs a lookup on the hot path.
  3. 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

FailureBlast radiusResponse
Entra ID downnew sessions onlyexisting tokens work until expiry; refuse new sessions
the AS downnew exchanges — i.e. new hopscached tokens serve in-flight work; no new delegations
Group AS downscreening onlyrefuse or escalate; never proceed unscreened
JWKS rotation missedeverything, suddenlycache keys with overlap; alarm on validation failure rate
a leaked mesh token5 minutes, one audience, narrowed scopesmTLS binding makes it unusable alone
a compromised agentits scopes, its chain positionit cannot escalate — narrowing is monotone and AS-enforced
clock skewintermittent validation failuresNTP + a small leeway; alarm on iat in the future
a delegation cycleone requestAS 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

  1. The Principal, built once at the channel. Every later design decision depends on it.
  2. One token exchange, one hop. Delegation semantics, act claim, narrowing. Prove it end to end before adding hops.
  3. Workload identity (SPIFFE/SVID) and mTLS. Removes the standing secrets, which is the largest single risk reduction available.
  4. Depth and cycle limits at the AS. Cheap now, and a vulnerability later if the agent framework is the only thing checking.
  5. JIT credentials at the gateway, audience-bound and resource-scoped.
  6. Federation to the second organization. Last, because it needs two teams and a trust agreement, and everything before it is unblocked.
  7. 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.