Warmup — Agent & Workload Identity, From Zero
Assumes HTTP and a working knowledge of hashing. Assumes nothing about OAuth, OIDC, JWTs, SPIFFE or token exchange. This is the densest phase in the track and the one that most repays reading slowly.
Table of Contents
- 1. Why agent identity is a new problem
- 2. Authentication, authorization, and the words in between
- 3. Tokens
- 4. OAuth 2.0 → 2.1
- 5. OIDC: the identity layer
- 6. Scopes and the narrowing algebra
- 7. Token exchange (RFC 8693)
- 8. Workload identity: SPIFFE and SPIRE
- 9. Sender-constrained tokens
- 10. Non-human identity as a lifecycle
- 11. Just-in-time credentials
- 12. Enterprise integration
- 13. Lab walkthrough
- 14. Success criteria
- 15. Common mistakes
- 16. Interview Q&A
- 17. References
1. Why agent identity is a new problem
Start with the thing that does not work, because everyone builds it first.
The service account. Give the agent fleet one credential with the union of every permission any agent might need. It is one line of configuration and it works immediately.
Then read the audit record it produces:
2026-03-12T14:22:03Z actor=svc-ai-platform action=payments.release amount=250000
Who released the payment? "The platform." Which user asked? Unknown. Was that user entitled to release AED 250 000? Nobody checked — the service account was. If the credential leaks, an attacker has every permission every agent has, forever, and revoking it stops all agents at once.
Now enumerate what makes an agent different from a service:
| Property | Consequence |
|---|---|
| It acts on behalf of a person | its authority must be bounded by that person's, not by its own |
| It acts across multiple hops | authority must propagate and narrow at each one |
| It discovers tools at runtime | you cannot enumerate its permissions at design time |
| It delegates to other agents | possibly across an organizational boundary |
| There are hundreds of them | and non-human identities already outnumber humans in most enterprises by a large multiple |
Every row breaks the service account. The mechanisms that survive are the four properties this phase builds, and each is a control an examiner will ask you to demonstrate:
Derived — from a verified assertion, never asserted by the caller. Narrowed — audience and scope shrink at every hop. Chained — the actor list is append-only and visible. Short-lived — seconds to minutes, so revocation is a timeout.
2. Authentication, authorization, and the words in between
Four words people use interchangeably and should not:
- Authentication (AuthN) — who is this? Verifying an identity claim.
- Authorization (AuthZ) — may they do this? A decision about a specific action.
- Delegation — A acts on behalf of B, and both are visible.
- Impersonation — A becomes B; the fact that it was A is erased.
The last two are the phase's central distinction, and §7.2 covers it properly.
Two more terms you need:
- Principal — the identity a decision is about. For an agent flow this is a composite: "agent A acting for user U", and both halves constrain what is allowed.
- Non-human identity (NHI) — any identity that is not a person. Services, workloads, bots, and now agents.
3. Tokens
3.1 The JWT structure
A JSON Web Token is three base64url segments separated by dots:
eyJhbGciOiJIUzI1NiIsImtpZCI6ImsxIn0 . eyJpc3MiOiJodHRwczovL2xvZ2luIn0 . 3f9a2c...
└────────── header ──────────────┘ └────────── payload ──────────┘ └ signature ┘
Header — alg (the signing algorithm), typ, and kid (which key signed it, so a verifier
can select from several during rotation).
Payload — the claims (§3.2).
Signature — over base64(header) + "." + base64(payload). The signature covers the encoded
form, which is why you must never re-serialize before verifying: JSON key order is not canonical,
and a re-encode changes the bytes.
Base64url, not base64: - and _ instead of + and / so it is URL-safe, and padding is
stripped. That last detail is a genuine trap — you must restore the = padding before decoding,
and forgetting it fails on exactly two thirds of otherwise-valid tokens. The lab tests every
padding case for this reason.
A note on the lab's _json_b64: it serializes with sorted keys and no spaces. JOSE does not
require canonical JSON; a deterministic test does. Without it the same claims produce different
tokens on different Python versions.
3.2 The claims, and who checks each one
| Claim | Means | Checked by | Attack if skipped |
|---|---|---|---|
iss | issuer | resource server | a token from a rogue issuer is accepted |
sub | the principal | everyone | wrong attribution in every audit record |
aud | who it is for | resource server | the confused deputy — §3.4 |
exp / nbf / iat | validity window | resource server | replay of an expired credential |
jti | unique id | replay cache | a one-time token used twice |
scope | what may be done | resource server | excessive agency |
act | the delegation chain | control plane, audit | you cannot tell agent-for-user from user |
cnf | proof-of-possession binding | resource server | a stolen bearer token works |
client_id | which client obtained it | audit | you cannot trace the entry point |
The claims nobody checks are the vulnerabilities. A verifier that validates the signature and stops has confirmed the token is authentic and nothing about whether it is for you, still valid, or sufficient.
3.3 The two classic JWT breaks
alg: none. The JWS spec includes an "unsecured" mode with no signature. A verifier that
reads the algorithm from the token and dispatches on it will happily accept a token with no
signature at all — because the attacker set alg to none and the verifier obeyed.
Algorithm confusion. A verifier configured for RS256 (asymmetric) is handed a token signed HS256 (symmetric) using the public key as the HMAC secret. The public key is public, so the attacker can forge freely, and a naive library that picks the algorithm from the header verifies it successfully.
Both have the same root cause and the same fix:
The verifier decides the algorithm. The token never does.
The lab's verify_signature compares header["alg"] against the signer's configured algorithm
and refuses a mismatch — and it uses hmac.compare_digest, because a short-circuiting == on the
signature leaks it one byte at a time to an attacker who can measure response times.
3.4 Audience, and the confused deputy
The confused deputy is a privileged intermediary tricked into using its authority for someone else. In token terms:
Service B holds a valid token that a user presented to B. B sends that same token to service C. If C does not check
aud, C accepts it — and B has just used the user's authority against a service the user never intended.
In an agent platform this is not hypothetical: agents call each other constantly, and forwarding the incoming token is the obvious implementation. It is also why every hop must exchange rather than forward (§7).
aud validation is the defence: a token minted for agent-platform is refused by core-banking,
full stop, regardless of how valid its signature is. It is the single most important check in
§3.2's table, and it is the one most commonly missing.
4. OAuth 2.0 → 2.1
4.1 The four parties
| Party | Is |
|---|---|
| Resource owner | the human who owns the data |
| Client | the application acting on their behalf (your channel, your agent) |
| Authorization server | mints tokens (Entra ID, in a bank) |
| Resource server | the API holding the data (core banking) |
OAuth exists so the client never sees the user's password: the user authenticates to the authorization server, which issues the client a scoped, expiring token.
4.2 The authorization code flow
user → client: "look at my payments"
client → AS: /authorize?client_id&redirect_uri&scope&code_challenge (browser redirect)
AS → user: authenticate, consent
AS → client: redirect back with a CODE (not a token)
client → AS: /token code + code_verifier + client_id (back channel)
AS → client: access token (+ ID token, + refresh token)
client → RS: Authorization: Bearer <access token>
The code is a one-time, short-lived, single-use reference. It travels through the browser (where things leak — history, referrers, logs); the token travels only on the back channel.
Three properties the lab enforces:
- Single use. A redeemed code is dead. The lab marks it used before checking expiry, so a replay is unambiguous.
- Exact redirect-URI matching. Prefix matching turns an open redirect into a code-interception attack. OAuth 2.1 requires exact.
- Client binding. A code issued to client A cannot be redeemed by client B.
4.3 PKCE, derived
The attack. A public client (a mobile app, a SPA) has no secret. If an attacker can intercept
the redirect — a malicious app registering the same custom URI scheme, say — they get the code and
can redeem it, because redemption needs only the code and a public client_id.
The fix (RFC 7636). Before starting, the client generates a random code_verifier and sends
its hash:
$$\text{code_challenge} = \text{base64url}(\text{SHA-256}(\text{code_verifier}))$$
The authorization server stores the challenge with the code. At redemption the client presents the verifier; the server hashes it and compares. An attacker with the intercepted code does not have the verifier and cannot derive it from the challenge (that is the preimage resistance of SHA-256).
Why S256 only. RFC 7636 also defines plain, where the challenge is the verifier. That
protects against nothing: an attacker who can intercept the code can intercept the challenge, and
the challenge is the verifier. OAuth 2.1 removes plain, and the lab raises on it.
Why PKCE is now mandatory for confidential clients too. A client secret protects against a different attacker (one who cannot see the redirect but can call the token endpoint). PKCE protects against code interception. They are orthogonal, and OAuth 2.1 requires both.
4.4 What OAuth 2.1 removed, and why
OAuth 2.1 is a consolidation, not a new protocol. The removals are the interesting part:
| Removed | Why |
|---|---|
| Implicit grant | returned tokens in the URL fragment, where they leak via history, referrers and logs |
| Resource-owner password grant | the client sees the user's password, which defeats OAuth's entire purpose |
| Bearer tokens in query strings | URLs are logged everywhere |
plain PKCE | protects against nothing (§4.3) |
| Prefix redirect-URI matching | open redirect → code interception |
And the additions: PKCE mandatory for all clients, and refresh tokens must be either sender-constrained or one-time-use with rotation.
Refresh-token rotation is worth knowing even though the lab omits it: each use returns a new refresh token and invalidates the old one. If an old one is reused, that is evidence of theft — someone has a copy — and the correct response is to revoke the entire token family, not just that token. That inference-from-reuse is a genuinely elegant piece of design.
4.5 Client credentials, and why it is the wrong default
The client-credentials grant is machine-to-machine: a client authenticates with its own secret and receives a token representing itself. No user involved.
It is correct for genuinely user-less work — a nightly batch, a health check.
It is wrong for an agent acting for a person, and the lab shows why by printing both audit chains side by side:
client credentials: batch-runner
delegation: u-42 -> orchestrator -> payments-investigator
The first cannot answer "who asked?", cannot be bounded by what the user is personally entitled to do, and attributes every action to the platform. Reaching for client credentials because it is simpler is the single most common wrong turn in this phase.
5. OIDC: the identity layer
OAuth 2.0 is about authorization — it says nothing about who the user is. OpenID Connect adds that as a thin layer, and its contribution is one artifact:
The ID token — a JWT about the user, audienced to the client.
| Access token | ID token | |
|---|---|---|
| About | what may be done | who the user is |
| Audience | the API | the client |
| Consumed by | the resource server | the client application |
| Contains | scopes | sub, name, email, auth_time, … |
Never send an ID token to an API. Its audience is the client; an API accepting it is failing
the check in §3.4. Conversely, never inspect an access token in the client to learn who the user
is — that is what /userinfo and the ID token are for, and an access token's format is not
guaranteed to be readable.
This confusion is why people say "OIDC and OAuth are the same thing". They are not: one issues authority, the other issues identity, and mixing the artifacts breaks audience validation.
6. Scopes and the narrowing algebra
A scope is a string naming a permission: payments.read, crm.write.
Three things that are not the same and are constantly conflated:
- Requested scope — what the client asked for.
- Granted scope — what the authorization server issued (⊆ requested, and ⊆ what the client is registered for).
- Effective permission — granted ∩ what the user may do ∩ what policy allows right now.
An agent should receive the task's scope, not the user's scope. A user entitled to release
payments does not mean an agent answering a question for them should hold payments.release.
Down-scoping at the boundary is the mechanism, and it is the practical form of least privilege in
this phase.
The narrowing algebra. The lab makes escalation structurally impossible:
def narrow_scopes(held, requested):
return normalize([r for r in requested if any(covers(h, r) for h in held)])
This function cannot return a scope outside held, for any input. That is a much stronger
guarantee than "we check for escalation", because there is no code path that grants.
require_no_escalation adds the second rule: refuse rather than silently drop. Returning a
smaller scope than requested lets a caller proceed believing it has authority it does not, and
fail later at a point far from the cause. An explicit error at the boundary is worth a great deal.
The lab supports one wildcard form (payments.*). Deliberately limited — a scope language with
real pattern matching becomes a policy engine, and then nobody can tell what a credential permits
by reading it. Policy belongs in Phase 09;
scopes should stay legible.
7. Token exchange (RFC 8693)
7.1 The problem it solves
User U's token is audienced to the agent platform. The platform's orchestrator must call the investigation agent, which must call core banking. Three options:
- Forward the token. The confused deputy (§3.4). Core banking either rejects it on audience — correct — or accepts it, which is worse.
- Use a service account. The user disappears (§4.5).
- Exchange it. Trade the token for a new one with a different audience, a narrower scope, and the actor recorded.
RFC 8693 standardizes the third. It is the backbone of multi-hop agent identity, and it is what makes Phase 03's delegation chain unforgeable.
7.2 Delegation versus impersonation
RFC 8693 supports both, and the difference is whether the chain survives.
Delegation — the new token keeps the user as sub and records the agent in act:
{ "sub": "u-42",
"act": { "sub": "payments-investigator",
"act": { "sub": "orchestrator" } } }
Note the nesting: the outermost actor is the most recent. That is fixed by the RFC and it is the opposite of what most people assume — worth checking before you read a chain during an incident.
Impersonation — the new token has the agent as sub and no chain. Downstream, the request
looks like the agent acting alone. The user is gone.
For a regulated platform the choice is not close. Delegation preserves the answer to "on whose behalf?", which is the question every audit asks. The lab disables impersonation by default and requires an explicit flag to enable it — and the test asserts that enabling it erases the chain, which is the point.
7.3 The four rules of an exchange
Every exchange must:
- Narrow the audience. The new token is for a more specific service. An exchange returning the same audience has achieved nothing and is refused.
- Subset the scope. Never widen. §6's algebra makes this structural.
- Append to the chain — and refuse a cycle. If the actor is already in the chain (or is the subject), you are forming A→B→A: a loop across owners that nobody can see whole.
- Shorten the lifetime.
min(requested, policy max, the parent's remaining life). A derived credential that outlives its parent is a privilege escalation in the time dimension — the user's session ends and the derived credential keeps working.
Plus a precondition the lab enforces with a may_delegate claim: not every token may be
exchanged. A credential minted for a leaf tool should not be tradeable onward, and marking that
explicitly is cheaper than reasoning about it later.
7.4 Walking three hops
From the lab's worked example:
| Hop | Audience | Scope | Chain | Life |
|---|---|---|---|---|
| user token | agent-platform | payments.read, payments.release | — | 600 s |
| → orchestrator | payments-investigator | payments.read, payments.release | u-42 → orchestrator | 120 s |
| → investigator | core-banking | payments.read | u-42 → orchestrator → payments-investigator | ≤ 118 s |
Read the last row as core banking sees it: user u-42 asked, the orchestrator delegated, the investigator is acting, it may only read, and this credential dies in under two minutes.
That is a sentence you can put in front of an examiner. The service-account alternative is "the platform did something."
8. Workload identity: SPIFFE and SPIRE
Everything above authenticates a user through a client. A separate question: how does a running process prove what it is?
The traditional answer is a secret in the environment — which must be provisioned, rotated, protected, and which is copyable. The population of such secrets in a bank is enormous and mostly unmanaged.
SPIFFE (Secure Production Identity Framework For Everyone) inverts the direction of trust:
The workload presents nothing. The platform observes properties it can verify — this pod, this namespace, this service account, this image — and issues an identity based on them.
Three concepts:
The SPIFFE ID — a URI naming a workload: spiffe://bank.ae/ns/agents/sa/investigator. The
authority is the trust domain; the path is the workload.
The SVID — the credential carrying the ID, as X.509 or JWT. Short-lived (minutes) and rotated automatically by the infrastructure. There is no secret to manage because there is nothing durable to steal.
Attestation — how the platform verifies. Node attestation proves the machine (a cloud instance-identity document, a TPM); workload attestation proves the process on it (its namespace, service account, image digest). SPIRE is the reference implementation.
Two rules the lab enforces, and both matter:
- All registered selectors must match. A registration for
ns=agents, sa=investigatormust not be satisfied by a workload presenting onlyns=agents— a subset match lets any workload in the namespace claim a narrower identity. - Ambiguity is refused, not resolved. If two registration entries match, the correct answer is an error. Guessing assigns identity nondeterministically, which is the worst possible property for an identity system.
Federation across trust domains is explicit. Two SPIFFE domains do not trust each other by
default; a federation relationship is configured, and the lab's mtls_authorize refuses a foreign
caller unless its domain is federated and it is allow-listed.
9. Sender-constrained tokens
A bearer token is authority in a string: whoever holds it, wields it. Steal it and you are the principal until it expires.
A sender-constrained token is bound to a key the holder must prove possession of. Stealing the string is no longer enough.
Two mechanisms:
- mTLS-bound (RFC 8705) — the token carries a thumbprint of the client's TLS certificate in
cnf. The resource server checks that the presenting connection used that certificate. - DPoP (RFC 9449) — the client signs a small JWT per request with a private key whose
thumbprint is in
cnf. Works without mTLS, which suits browsers and mobile.
The lab implements the cnf check generically: a token carrying a thumbprint is refused unless
the presented key matches. The test — "stolen token, wrong key, refused" — is the whole argument.
When is it worth the complexity? For short-lived credentials in a controlled network, bearer is often acceptable, because a 60-second token has little value. For anything crossing a boundary, anything long-lived, or anything that moves money, binding is the difference between "theft is sufficient" and "theft is not sufficient."
Note the interaction with §8: if your workloads already have SVIDs and mTLS, sender-constraint is nearly free — the certificate is already there. That is a good reason to do SPIFFE first.
10. Non-human identity as a lifecycle
An agent is an identity, and identities have lifecycles. The lab's:
registered ──► approved ──► active ⇄ suspended
│ │ │ │
└──────────────┴───────────┴────────┴──► retired
Each state means something operationally:
- registered — it exists in the inventory; it cannot obtain credentials.
- approved — a human has reviewed it (scopes, owner, purpose).
- active — it may obtain credentials.
- suspended — reversible stop. Its next credential request fails.
- retired — terminal.
Two design points that matter more than they look:
Every NHI has a named human owner. This is the single most common finding in an identity audit:
credentials that exist, work, and belong to nobody, because the team that created them was
reorganized. The lab makes owner mandatory at registration.
Revocation latency is the credential TTL. Suspension stops the next issuance; an already-issued credential keeps working until it expires. That is the honest statement, and it is precisely why 60-second lifetimes matter — they turn "revocation" from a distributed-systems problem into a wait.
If you need faster, you need a second channel: a kill switch that the resource server consults, which reintroduces the availability question from Phase 00. Most platforms conclude that short TTLs plus a kill switch for the small set of high-impact identities is the right shape.
11. Just-in-time credentials
Putting it together: a credential minted at the moment of use, for one audience, with one task's scopes, expiring in seconds, never stored.
broker.issue(CredentialRequest(
identity_id="payments-investigator",
audience="core-banking",
scopes=("payments.read",),
subject_token=user_token, # keeps the user in the chain
bind_to_key="pk-investigator", # sender-constrained
lifetime_seconds=60,
))
Why each property earns its place:
| Property | Removes |
|---|---|
| Not stored | the secret-in-config leak, the secret-in-image leak, the secret-in-logs leak |
| 60-second life | most of the value of any leak, and the need for a rotation process |
| One audience | its usefulness anywhere else |
| Task scopes | excessive agency |
| Key-bound | theft-is-sufficient |
And the branch that carries the phase's argument: with a subject token, the broker exchanges; without one, it mints a workload credential. The workload credential can do only what the workload may do on its own behalf — so anything requiring a user's entitlement must supply the user's token, and the platform cannot accidentally act as itself.
12. Enterprise integration
None of this is built from scratch in a bank. What you integrate with:
Microsoft Entra ID is almost certainly the authorization server. Three features map directly:
- Managed identity — an Azure resource gets an identity with no secret; the platform issues tokens to it. Azure's answer to §8.
- Workload identity federation — an external workload (a Kubernetes service account, a GitHub Actions run) exchanges its own token for an Entra token, with no stored secret. This is how you remove credentials from CI/CD.
- On-behalf-of (OBO) — Entra's flow for a middle-tier service calling a downstream API with the user's identity. It is RFC 8693's delegation case, and it is what you will actually configure.
PAM (privileged access management) brokers, records and time-bounds privileged access. Agents touching privileged systems must go through it, not around it — which usually means the agent requests a session and PAM issues the credential, keeping the recording and the time bound intact.
Secrets management (Key Vault, Vault) is for what remains after §11 — and the goal is that very little does. A useful metric: count the long-lived secrets in the platform and drive it toward zero.
The integration question that decides your design: can Entra issue tokens with the act claim
you need, or do you need your own STS? Most banks end up with a thin internal STS that consumes
Entra tokens and issues platform-scoped ones with the chain — which is exactly the lab's
TokenExchange.
13. Lab walkthrough
Work Lab 01 in this order — later sections depend on earlier ones.
b64url_encode/decode(§3.1). The padding restore is the whole trick.Claims.to_payload/from_payload,_nest_actors/_flatten_actors(§3.2, §7.2). Nesting is latest-outermost; the round trip is earliest-first._json_b64,Signer(§3.3). Canonical JSON; algorithm from the signer;compare_digest.Verifier.verify(§3.2, §3.4). Ten checks in the documented order. Signature first.- Scopes (§6).
narrow_scopesmust be structurally incapable of widening. code_challenge,AuthorizationServer(§4). Mark a code used before the expiry check.TokenExchange.exchange(§7.3). Verify, then the four rules, then mint.- SPIFFE/SPIRE (§8). All selectors must match; ambiguity is an error.
IdentityRegistry(§10). A declared transition table; a mandatory owner.JitCredentialBroker(§11). The subject-token branch is the lesson.
Then python solution.py and read the six sections against §§3–11.
14. Success criteria
Without the guide open:
- Explain why a service account fails for agents, with the audit record it produces.
- Give the four properties of an agent credential.
- Name the claims a resource server must check and the attack each prevents.
- Explain both classic JWT breaks and their single shared fix.
- Explain the confused deputy concretely, in an agent platform.
- Derive PKCE and explain why
plainis useless. - List what OAuth 2.1 removed and why.
- Explain refresh-token rotation and what a reuse implies.
- Distinguish an ID token from an access token by audience.
- Explain why an agent gets the task's scope, not the user's.
- State the four rules of a token exchange.
- Explain delegation vs impersonation and which a bank wants.
- Explain SPIFFE's inversion of trust, and what "secret-less" means.
- Explain why all selectors must match and why ambiguity is refused.
- Explain sender-constraint and when it is worth the complexity.
- State the NHI lifecycle and why every one needs a human owner.
- Explain why revocation latency equals the credential TTL.
15. Common mistakes
A service account for the agent fleet. Every action attributed to the platform.
Forwarding the incoming token to the next hop. The confused deputy.
Not checking aud. The single most commonly missing check.
Reading alg from the token. alg: none and algorithm confusion.
== on a signature. Leaks it byte by byte.
Forgetting base64url padding. Fails on two thirds of tokens.
Prefix redirect-URI matching. Open redirect → code interception.
plain PKCE. Protects against nothing.
Sending an ID token to an API. Audience is the client.
Giving an agent the user's full scope. Excessive agency by default.
Silently granting less than requested. The caller fails far from the cause.
An exchange that does not narrow. It has achieved nothing.
A derived token that outlives its parent. Escalation in the time dimension.
No cycle check on the chain. A→B→A across two owners, invisible to both.
Impersonation because it is simpler. The user disappears from the audit record.
Subset selector matching in attestation. Any workload in the namespace claims the identity.
Guessing when two registrations match. Nondeterministic identity.
Implicit cross-domain trust. Federation is always explicit.
An NHI with no human owner. The standard audit finding.
Assuming suspension is instant. It takes effect at the next issuance.
16. Interview Q&A
Q: Design the identity model for our agent platform.
A: "Four properties, and each is a control I can demonstrate: derived, narrowed, chained,
short-lived. The user authenticates to Entra through the channel with authorization-code plus
PKCE, and gets an access token audienced to the platform. Every hop after that is an RFC 8693
token exchange, not a forward — forwarding is the confused deputy, and a service account erases
the user. Each exchange narrows the audience to the next service, subsets the scope to what this
task needs rather than what the user may do, appends the actor to the act chain, and takes the
minimum of the requested lifetime, policy, and the parent's remaining life — because a derived
credential outliving its parent is escalation in the time dimension. Underneath, workloads get
SPIFFE identities from attested properties rather than secrets, and credentials are bound to a key
via cnf so theft alone isn't sufficient. The result is that core banking sees 'user u-42 asked,
orchestrator delegated, investigator is acting, read-only, expires in 90 seconds' — which is a
sentence I can put in front of an examiner."
Q: Why not a service account?
A: "Because of the audit record it produces. It says actor=svc-ai-platform and nothing else —
you can't answer who asked, you can't bound the action by what that person is personally entitled
to do, and a leak gives an attacker every permission every agent has, permanently. Then the
structural problems: an agent acts on behalf of a person, so its authority should be derived and
bounded; it acts across hops, so authority must narrow at each one; it discovers tools at runtime,
so you can't enumerate permissions at design time; and there are hundreds of them, so one
credential means either over-privileging everything or maintaining hundreds of accounts by hand.
Every one of those breaks the model. Client credentials is right for a genuinely user-less
workload — a nightly batch — and wrong the moment a person is involved."
Q: Walk me through what happens to the token across three hops.
A: "Hop zero: the user's token is audienced to the agent platform, scoped to what the channel
requested, with no actor chain. Hop one: the orchestrator exchanges it — verifies it first, then
mints a new token audienced to the investigator agent, with the same or narrower scope, sub still
the user, and act containing the orchestrator. Hop two: the investigator exchanges again —
audience becomes core-banking, scope narrows to payments.read because reading is all this task
needs, and the chain becomes user → orchestrator → investigator. Each token's lifetime is the
minimum of what was asked for, what policy allows, and what remains of the parent's life. And the
chain at each hop is derived from a verified token, never from anything in the request — a
callee that could assert its own position could erase a hop, and it would be the interesting one.
Refusals along the way: an exchange that doesn't narrow the audience, one that widens scope, one
that would form a cycle, and one from a token marked non-delegable."
Q: What's the confused deputy, in this system?
A: "A privileged intermediary tricked into using its authority for someone else. Concretely: the
investigator agent holds a token a user presented to it, and forwards that same token to core
banking. If core banking doesn't validate aud, it accepts a credential that was never intended
for it — and the agent has used the user's authority against a service the user never chose. The
defence is audience validation at every resource server, full stop, regardless of how valid the
signature is. And the reason token exchange exists is that forwarding is the obvious
implementation — you have a valid token, the next service needs one, so you pass it along. Every
platform builds that first."
Q: Explain PKCE, and why OAuth 2.1 made it mandatory for everyone.
A: "The attack is code interception: a public client has no secret, so anyone who intercepts the
redirect — a malicious app registering the same URI scheme — can redeem the code with just the
public client id. PKCE fixes it by having the client generate a random verifier, send its SHA-256
hash as the challenge at the authorize step, and present the verifier at the token step. An
attacker with the code doesn't have the verifier and can't derive it from the hash. plain mode,
where the challenge is the verifier, protects against nothing, which is why 2.1 removes it. And
it's mandatory for confidential clients too because a client secret defends against a different
attacker — one who can call the token endpoint but can't see the redirect — so the two are
orthogonal rather than alternatives."
Q: What does SPIFFE change?
A: "It inverts the direction of trust. Normally a workload presents a secret to prove what it is, which means the secret must be provisioned, rotated, protected, and can be copied. SPIFFE says the workload presents nothing: the platform attests properties it can independently verify — this node via an instance-identity document or TPM, this pod's namespace, service account and image — and issues a short-lived SVID based on them. There's no durable secret to steal, and an exfiltrated SVID is useless to an attacker who can't reproduce the selectors. Two implementation rules matter: all registered selectors must match, because a subset match lets any workload in the namespace claim a narrower identity; and if two registrations match, refuse rather than guess, because nondeterministic identity is the worst property an identity system can have. And it composes nicely with sender-constrained tokens — once workloads have certificates for mTLS, binding tokens to them is nearly free."
Q: An agent is compromised. How fast can you stop it?
A: "Suspension takes effect at the next credential request, so worst case is the credential TTL — which is exactly why I'd run 60-second lifetimes rather than hourly ones. That turns revocation from a distributed cache-invalidation problem into a wait. If a minute is too long for a specific identity, you need a second channel: a kill switch the resource server consults, which reintroduces a synchronous dependency and the fail-open/fail-shut question from the platform's availability model. Most platforms land on short TTLs everywhere plus a kill switch for the small set of high-impact identities. What I'd also want is the blast radius already bounded: because each credential is audienced to one service and scoped to one task, a compromised agent can't pivot — it has a 60-second read-only token for one API, not a service account with the union of everything."
17. References
Core specifications
- OAuth 2.1 (draft) — the consolidation; read the "differences from 2.0" section first.
- RFC 6749 (OAuth 2.0) and RFC 9700 (OAuth 2.0 Security Best Current Practice) — the BCP is where most of 2.1's changes come from and it explains the attacks.
- RFC 7636 — PKCE.
- RFC 8693 — OAuth 2.0 Token Exchange. §2 (the request) and §4.1 (the
actclaim) are the parts this phase is built on. - RFC 7519 (JWT), RFC 9068 (JWT profile for access tokens), RFC 7515 (JWS),
RFC 7638 (JWK thumbprint), RFC 7800 (
cnf). - RFC 8705 (mTLS client authentication and certificate-bound tokens) and RFC 9449 (DPoP).
- OpenID Connect Core 1.0 — the ID token, and §3.1.3.7 on ID-token validation.
Workload identity
- SPIFFE — the SPIFFE ID and SVID specifications, and the concepts pages on trust domains and federation.
- SPIRE documentation — node and workload attestation, registration entries, the Workload API.
Enterprise
- Microsoft Entra ID — on-behalf-of flow, managed identities, workload identity federation. These three are what you will actually configure.
- HashiCorp Vault — dynamic secrets and identity brokering, as the JIT-credential pattern in production.
Background
- Hardt's OAuth talks and the IETF OAuth working-group drafts, for why the protocol looks like it does.
- OWASP — Top 10 for LLM Applications, Excessive Agency, which is what scope narrowing exists to close.
- Any NHI-security industry report for the human-to-non-human identity ratio; the number moves, and the direction does not.