All lessons › Function B — Securing AI Architectures › Securing the Architecture — Identity and Ingress
B2.3

Delegation that narrows, and survives audit

B2
▶
Lightboard recording

Not recorded yet. The lesson below is complete and runnable today — the video is added later without touching this page.

Video placeholderpublishes automatically from recordings/B2.3.mp4
Risk

Subset-only lets a privileged user hand an agent authority it must never hold; ceiling-only lets the agent exceed the person who asked.

Control

Token exchange that intersects presented scope with the actor's ceiling, and records the chain.

The lab

Run both narrowing rules against a request that passes one and fails the other.

◉Use case relevance

The access token your agent holds is a password: whoever reads it out of a log can spend it. Three published standards fix that — an SVID says which workload is calling, RFC 8693 says on whose authority, and RFC 8705 binds the token to the certificate that earned it. Most estates implement the middle one and stop.

At CyberTravels. identity.token_exchange in your tree lets any hop ask for anything the human may delegate. You make it narrow, and you make act nest so actor_chain reads dana => advisor => workflow instead of naming only the last agent to touch it.

◴What this lesson is — Day 0, Day 1, Day 2

Making a delegation chain narrow rather than merely exist: each hop bounded by what the hop before it held, and the actor claim nested so an investigator reads every hop instead of the last one.

Day 0 — why

Subset-only delegation lets a privileged user hand an agent authority it must never hold; ceiling-only lets the agent exceed the person who asked.

Day 1 — how

Token exchange that intersects the presented scope with the actor's ceiling, and records the chain.

Day 2 — measure

Delegations whose result sits inside both the request and the ceiling, and the recorded depth of each chain.

Measure this

what to measuredenominator or target
delegations whose result sits inside both the request and the ceilingshare of delegations
delegation chain depthrecorded per chain

▦The framework, and how it works

   AI agent pod                authorization         downstream
                                  server              service
   +-------------+            +-------------+     +-------------+
   | X.509 SVID  | --mTLS-->  | RFC 8693    | --> | re-derives  |
   | spiffe://.. |  layer 1   | exchange    |     | x5t#S256    |
   |             |            |             |     | from ITS    |
   | user token  | ---------> | sub = user  |     | own TLS     |
   +-------------+  layer 2   | act = agent |     | connection  |
                              | cnf = x5t   |     +-------------+
                              +------+------+        layer 3
                                     |
                     scope issued = requested
                                  & presented       (subset)
                                  & actor ceiling   (never-exceed)

Mitigates: T3 Privilege Compromise · T8 Repudiation · T14 Human Attacks.

The agent has its own identity now. This lesson is about carrying the user's authority alongside it — without losing it, amplifying it, or handing it to whoever picks the token out of a log.

Three layers do that, and each one answers a question the layer above it cannot. Skipping any of them leaves a specific, named hole.

Layer 1 — mTLS with an X.509 SVID. Is this the workload it claims to be? The agent pod presents a client certificate whose URI SAN is a SPIFFE ID: spiffe://cybertravels.com/ns/prod/sa/agent-alpha. The platform issues it on attestation (B2.2), it lives minutes rather than months, and it rotates without anybody being told. Nothing downstream trusts a name in a header again.

Layer 2 — OAuth on-behalf-of, RFC 8693 token exchange. On whose authority? The agent presents two tokens: the user's (subject_token) and its own SVID (actor_token). The authorization server returns one access token whose sub is still the user and whose act claim names the agent. Delegation is written down rather than inferred, and it nests — act.act records the hop before — so alice → orchestrator → agent-alpha survives into the audit log.

Layer 3 — RFC 8705 certificate-bound tokens. Is the presenter the one it was issued to? The issued token carries a cnf claim holding x5t#S256: the SHA-256 thumbprint of the client certificate that asked for it. The downstream service recomputes the thumbprint of the certificate on its own TLS connection and compares. A bearer token is a password; a bound token is useless to anyone holding it without the private key that earned it.

On top of the three layers, delegation still has to narrow, and it has to satisfy two rules rather than one:

Subset of presented. The issued token carries no more scope than the incoming one. Stops the agent inventing authority.

Within the actor's ceiling. The issued token carries no more than the receiving agent may ever hold. Stops a privileged user handing an agent authority the agent must never have.

Neither is sufficient alone, and they fail in opposite directions. Subset-only lets an admin's request give a low-trust agent payments:refund — legitimately, and it looks correct in every log. Ceiling-only lets an agent exceed the person who asked, which is B1.6. The issued scope is the intersection.

On the standards. RFC 8693 (token exchange) and RFC 8705 (mTLS client authentication and certificate-bound access tokens) are published and widely implemented — none of this is future work. The IETF OAuth working group additionally has a live draft for AI agents acting on a user's behalf, which adds agent-specific metadata to the same exchange. It is a draft, not an RFC, and nothing in this lesson depends on it: all three layers are buildable on RFC 8693 and RFC 8705 as they stand today.

2 · The three layers, and what each one refuses

agent pod
🤖 AI agent
holds an X.509 SVID issued on attestation, plus the user's token from the request
spiffe://.../sa/agent-alpha
›
layer 1 · mTLS
🔒 client certificate
URI SAN carries the SPIFFE ID. Minutes long, rotated automatically
REFUSES A NAME IN A HEADER
›
layer 2 · RFC 8693
🎫️ authorization server
subject_token is the user, actor_token is the SVID. One token comes back: sub is still the user, act names the agent
REFUSES WIDENING
›
layer 3 · RFC 8705
🔑 cnf / x5t#S256
the thumbprint of the certificate that asked for the token, stamped into the token itself
REFUSES A STOLEN TOKEN
›
downstream
💳 payments API
re-derives the thumbprint from its own TLS connection and compares it before reading a single scope
R1
Each layer answers a question the one before it cannot: is this the workload, on whose authority, and is the presenter the one the token was issued to. The code below builds all three, then steals the token.
What this control closes. Both narrowing rules, at every hop, plus a binding that makes the token worthless off the connection that earned it.

3 · Layers 1 and 2 — the exchange, and the token it issues

4 · Layer 3 — steal the token and try to use it

5 · The same three layers, against real Keycloak

Everything above is the protocol, modelled in the standard library so this skill runs with the internet switched off. That proves RFC 8693 and RFC 8705 work. It does not prove the product you are about to deploy implements them, which is a different question and has a different answer.

labs/tools/keycloak-obo/ downloads Keycloak 26.0.7, starts it with mTLS, configures this realm and runs the same three checks. Three of them behaved as the specifications describe:

the agent asks for its own token over plain HTTP, with no certificate:
  {"error":"invalid_request",
   "error_description":"Client Certification missing for MTLS HoK Token Binding"}

its x5t#S256 thumbprint (computed with openssl):
  BuTPvYMaI3z-suLCcWsnFHDCv_6VQdDrYwLlf70Sjfg
the cnf claim on the token Keycloak issued:
  {"x5t#S256": "BuTPvYMaI3z-suLCcWsnFHDCv_6VQdDrYwLlf70Sjfg"}

the same token, presented to a resource server that compares:
  legitimate agent  HTTP 200
  the thief         HTTP 403  cnf mismatch - token was not issued to this client
  no client cert    HTTP 403  no client certificate presented

The thief's certificate is signed by the same CA and is therefore trusted. Trust is not what separates them.

And two did not. These are the ones worth carrying out of this lesson:

Keycloak's standard token exchange emits no act claim — with or without an actor_token. It returns 200 either way and produces the same token. azp does name the agent, so the information is not lost, but azp is one value and act nests: a three-hop chain has nowhere to go. The delegation chain reconstructed in the next cell is something you write a protocol mapper for.

The exchange drops the certificate binding. The direct token carries cnf. The exchanged token — requested over the same mTLS connection, with the same certificate — does not. So the token the agent actually carries downstream, the one issued for acting on alice's behalf, is a bearer token again, and the theft the binding was bought to prevent is back.

Neither is a reason not to build this. Both are reasons to check your own deployment rather than assume the control is on because the feature exists.

6 · What the audit trail can now answer

Every hop is on the token, so the chain reconstructs from the token alone rather than by correlating four services' logs on timestamp. This is the thing B1.14 could not do.

7 · Verifying the chain, as a skill

The two findings above are what you get from checking a deployment rather than reading its design document, and CyberTravels has four agents and a payments API to check. The procedure walks every hop — user, agent, MCP server, tool, downstream — looks for token passthrough, and refuses to accept a matching sub as proof of delegation, because impersonation produces one too. This is the file in this repository:

▶Real time execution as skill

The skill — skills/attestation/identity-chain-verifier/SKILL.md

name: identity-chain-verifier
description: >-
  Verify workload identity and end-to-end on-behalf-of propagation across
  user, agent, MCP server, tool and downstream. Use to check for token
  passthrough, to distinguish delegation from impersonation, or when asked
  whether a downstream can attribute an action to the original user.
allowed-tools: Bash, Read, Grep

Identity Chain Verifier

Controls: Control 3 — workload identity and OBO attribution

Confidence: HIGH

Delegation is cryptographically distinguishable from impersonation, and token passthrough is visible in the audience claim. This control is genuinely verifiable.

When to use this

When a deployment claims to act on behalf of a human, and whenever that claim appears in an audit trail. Delegation is impossible without an actor token, so the presence of one is proof rather than assertion — which is why this control reaches HIGH confidence and most do not.

Procedure

  1. Establish workload identity. Read the registration entry and its selectors. For an X.509 credential the identity is in the SAN URI; for a JWT credential it is the subject. Confirm it matches the deployment manifest.
  1. Walk every hop. For user → agent → MCP server → tool → downstream, record the principal, the audience, and the token type at each hop.
  1. Check for token passthrough — the finding that matters most. A server must not forward the token it received to a downstream. Each hop must carry a distinct, audience-scoped token. A repeated audience across two hops is passthrough, and it is the mechanism behind the confused-deputy class.
  1. Distinguish delegation from impersonation. In token exchange, delegation is impossible without an actor token: the presence of an actor token, and a corresponding actor claim in the issued token, is what proves the chain is delegation rather than the agent simply becoming the user.
  1. Check credential hygiene. Short lifetime, rotation, and — where mutual TLS is possible — prefer certificate credentials over bearer tokens, which are replayable. Flag every JWT-only hop.

Example

Input — the fixture committed at the top of scripts/identity_chain_verifier.py. Edit it and re-run: the buckets, counts and verdicts below are derived from it, not hard-coded.

Output — the opening lines of a real run:

the access token the payments API will actually see:

{
  "act": {
    "sub": "spiffe://cybertravels.com/ns/prod/sa/agent-alpha"
  },
  "aud": "https://payments.cybertravels.internal",
  "cnf": {

The run continues past this. test_skills.py executes the script on every build, with no model configured — so what CI proves is that it runs and refuses legibly, not that it prints these lines. The output above is a recorded run against a real model, and nothing re-diffs it: a model's answer is not reproducible, and this repository does not claim otherwise anywhere it can be checked.

Output contract

{
  "deployment_id": "str",
  "chain": [
    {"hop": "user|agent|mcp|tool|downstream", "principal": "str",
     "audience": "str", "token_type": "x509|jwt",
     "mode": "delegation|impersonation|passthrough", "ttl_seconds": 0}
  ],
  "passthrough_violations": [{"from_hop": "str", "to_hop": "str", "audience": "str"}],
  "delegation_proven": true,
  "jwt_only_hops": ["str"],
  "verdict": "PASS|PARTIAL|FAIL"
}

Failure modes

  • Accepting a matching subject as proof of delegation. Impersonation also produces the right subject. The actor claim is the difference.
  • Not checking the audience. Passthrough is invisible without it.
  • Treating a long-lived JWT as equivalent to a rotated certificate.

Three ways to do this lesson. Pick one. They run the same procedure against the same code — what changes is who drives it, and how much you see.

1In your AI agent — you are taught

The agent reads the lesson, explains the idea, fetches the code, runs the check and answers your questions as you go. This is the one to take if you are here to learn.

Install it, once. From the folder you cloned. --all does every agent it finds on your machine:

python3 scripts/install_skills.py --all --lessons B2.3

Then restart your agent and open it in that folder. On Windows, if python3 says Python was not found, use python instead.

Then pick b2-3-delegation-that-narrows:

agentinstall just for this agentstart it
Claude Codepython3 scripts/install_skills.py --tool claude --lessons B2.3type /b2-3-delegation-that-narrows
GitHub Copilot (VS Code)python3 scripts/install_skills.py --tool copilot --lessons B2.3type /b2-3-delegation-that-narrows in Chat
Cursorpython3 scripts/install_skills.py --tool cursor --lessons B2.3type / in Agent chat and search for it
Codexpython3 scripts/install_skills.py --tool agents --lessons B2.3type $b2-3-delegation-that-narrows, or run /skills

2As one Python command — you just run it

No agent, no conversation. The same lesson executes and prints the same readback. Take this one if you have no assistant, if you are reviewing rather than learning, or if you want the result without the teaching around it.

python3 scripts/lesson.py B2.3

3Read the code, and the difference — nothing runs

Neither of the above, and no model needed. The first command writes CyberTravels exactly as it stood at the end of this lesson; the second prints the diff — the lines this lesson added or changed, and nothing from any later one.

python3 scripts/checkpoint.py --at B2.3 --out work/cybertravels
python3 scripts/checkpoint.py --at B2.3 --diff

On Windows, type python wherever these say python3 — and use \ instead of / in the paths. Everything else is identical. A0.0 step 2 explains why.

Ways 1 and 2 need a model; way 3 does not. A signed-in Claude Code CLI needs no API key, and any OpenAI-compatible endpoint works too. With neither, the audit exits 2 and says so rather than inventing an answer. A0.0 sets this up.

Ways 1 and 2 both end with the same readback: What I did, What changed, The number and Read this next. A step that was skipped or refused is reported as exactly that, never as a pass.

If you did the last lesson’s challenge, expect this to stop. Most challenges ask you to edit the code, and ways 1 and 2 will not overwrite your edits: you get stopped, not a failure, with two options — --out for a fresh folder that keeps your work, or --force to discard it and start this lesson clean.

✓What you just proved

The verifier skill loads and reports its shape: the description an agent routes on, the tools it may use, and a procedure that walks every hop of the chain rather than checking the token it was handed. Read its failure modes against the Keycloak findings above — a matching sub is not delegation, and a chain with no audience check cannot see passthrough at all.

✎Your turn

Find your token exchange and check three things: does it set an act claim, does it check the actor's ceiling as well as the subset rule, and does anything downstream look at cnf? Most implementations do the subset rule only — it is the one the specification example shows.

Tools used

SPIFFE/SPIREKeycloakRFC 8693 token exchangeRFC 8705 mTLS binding

Skill source: skills/attestation/identity-chain-verifier

← PreviousB2.2 · Bootstrapping the first credential