All lessons › Function A — Getting Started — Building Agentic AI › Build the Agent — From a Loop to a Running Platform
A1.4

Delegation — one token per action

A1
▶
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/A1.4.mp4
Risk

An agent holding a long-lived token with every scope is one prompt away from using all of them.

Control

Per-action exchange, least privilege keyed to the human's role, enforced at the resource server.

The lab

Exchange a token, then watch a traveller's role refuse to delegate a refund scope.

◉Use case relevance

The token has every scope because that never fails. It is the setting that makes the demo smooth and it is also the setting under which one ambiguous sentence in a vendor document turns a read into a refund. The alternative costs a function call per action and closes the gap entirely.

At CyberTravels. cybertravels/identity.py. Sign in as Dana and ask for a refund: the exchange refuses, because a traveller's role cannot delegate payments:refund, and the resource server is never even asked.

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

RFC 8693 token exchange: one audience, one scope, two minutes, minted after the model has chosen and verified at the resource server — with least privilege keyed to the human's role rather than the agent's.

Day 0 — why

An agent holding a long-lived token with every scope is one ambiguous sentence away from using all of them.

Day 1 — how

Exchange a token per action — one audience, one scope, two minutes — and verify it at the resource server rather than logging it.

Day 2 — measure

Scopes a delegated token carries: one. And the count of refusals the exchange produced for scopes the human's role may not delegate.

Measure this

what to measuredenominator or target
scopes carried by one delegated tokencount; target one
refusals for scopes the human's role may not delegatecount per run batch

▦The framework, and how it works

        ONE TOKEN, ONE ACTION

   user token ─┐
               ├─► exchange ─► sub=human  act=agent
   agent NHI  ─┘               aud=ONE    scope=ONE   exp=120s
                                    │
                                    ▼
                            resource server VERIFIES
                            (the step most systems skip)

An agent needs authority to act, and the question is how much, for how long, and obtained when.

The comfortable answer is a long-lived token with every scope the agent might ever need. It is comfortable because it never fails. It is wrong because the agent is one ambiguous instruction away from using all of it.

One token, one action

RFC 8693 token exchange takes the human's token and the agent's workload identity and returns a third token:

  sub    the human            ── still the person who asked
  act    the agent            ── who is acting for them
  aud    one resource server  ── useless anywhere else
  scope  one scope            ── nothing adjacent
  exp    120 seconds          ── and then it is gone

That token is minted after the model has chosen a tool, for that tool, and discarded. The model never sees it and never asks for one.

Least privilege is keyed to the human, not the agent

The exchange refuses a scope the human's own role may not delegate. Dana is a traveller, so however the model is prompted, argued with, or injected, it cannot obtain payments:refund on her behalf — the resource server is never even asked. That is defence in depth stated precisely: two independent things have to fail, and the second one does not depend on the model at all.

And the resource server verifies anyway

Minting a correct token is half of it. The other half is that verify_delegated runs at the top of every MCP tool and refuses on signature, audience, scope, actor registration or expiry. This is the step most systems skip — they log the actor claim and act regardless, which turns the delegation chain from a control into a description.

2 · Exchange one, then watch a role refuse

Do this

1. Start Python in the folder you cloned. 2. Type this in. It asks for exactly the same refund twice — first as Dana the traveller, then as Priya in finance — and prints what happened each time:

from cybertravels import identity, config

for who in ("dana", "priya"):
    user  = identity.mint_user_token(who)
    agent = identity.mint_agent_token("workflow")
    try:
        identity.token_exchange(user, agent,
                                scope="payments:refund",
                                audience=config.AUD_INTERNAL_MCP)
        print(who, "-> minted")
    except identity.IdentityError as e:
        print(who, "-> REFUSED:", e)

3. Read the two lines it prints:

dana   -> REFUSED: role 'traveller' may not delegate 'payments:refund'
          (allowed: ['bookings:read', 'kb:read', 'vendor:read'])
priya  -> minted

4. Now notice the three things that did not happen to Dana's request. The model was never asked anything. No booking was looked up. The server that actually moves money was never contacted at all — there was no token to show it. The request stopped two steps before the thing it wanted to change.

5. And notice that Priya's identical request worked. That is what makes Dana's refusal a decision about who she is, rather than a broken button. Same code, same request, different person.

If the first line failed with an import error, you need one package: type pip install PyJWT (or python -m pip install PyJWT on Windows) and try again. The token here is a real signed token, not a pretend one, and that is the library that signs it.

The skill below verifies that a delegation chain is complete and enforced rather than merely recorded. B2.6 runs it against a system somebody else built; you are running it against yours.

The skill

▶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 A1.4

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

Then pick a1-4-delegation:

agentinstall just for this agentstart it
Claude Codepython3 scripts/install_skills.py --tool claude --lessons A1.4type /a1-4-delegation
GitHub Copilot (VS Code)python3 scripts/install_skills.py --tool copilot --lessons A1.4type /a1-4-delegation in Chat
Cursorpython3 scripts/install_skills.py --tool cursor --lessons A1.4type / in Agent chat and search for it
Codexpython3 scripts/install_skills.py --tool agents --lessons A1.4type $a1-4-delegation, 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 A1.4

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 A1.4 --out work/cybertravels
python3 scripts/checkpoint.py --at A1.4 --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

A delegated token whose subject is the human and whose actor is the agent, addressed to one audience with one scope — and a refusal, with the reason, when a traveller's role is asked to delegate a refund.

✎Your turn

Take a token minted for the internal server and present it to the vendor server. Read the refusal. Then remove the audience check and watch the same token work in both places.

Tools used

OAuth 2.0 Token ExchangeKeycloak

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

← PreviousA1.3 · Identity — the human, the workload, and the call