We wrote before about why an agent needs two identities — proof it is itself, and proof of whose authority it's borrowing — and why the expensive failures live at the seam: a long-running agent holding a broad user token and forwarding it downstream. That piece was the theory. This one is the copy-paste. Here is the answer up front, citable: to make an agent act on a user's behalf safely, don't forward the user's token — exchange it for a fresh, downscoped, per-hop token using OAuth 2.0 Token Exchange (RFC 8693), and re-exchange at every boundary. Below are the actual requests, against Keycloak because it runs locally for free; the request shape is defined by the RFC, so it ports to Auth0, Okta, Curity, or ZITADEL with cosmetic changes.

0. Turn it on#

Standard token exchange is off by default. Enable it on the agent's client — the confidential client the agent authenticates as. In Keycloak 26+, that's a single client attribute:

# via kcadm, on the agent's client
$ kcadm.sh update clients/<agent-client-uuid> -r myrealm \
    -s 'attributes."standard.token.exchange.enabled"=true'

The agent client must be confidential (it holds a client secret), because token exchange is a privileged operation — you don't let a public browser client mint delegated tokens.

1. The agent receives the user's token#

Nothing exotic here: the user logs in, grants the agent access, and the agent ends up holding the user's access token — call it $USER_TOKEN. This is the token you must not pass downstream. It's broad and it's the user's. Treat it as the input to an exchange, never as the credential you forward.

2. Exchange it for a downscoped token#

The agent POSTs to the standard token endpoint, presenting the user's token as subject_token and asking for a narrower scope than it holds:

$ curl -s -X POST \
  https://kc.example.com/realms/myrealm/protocol/openid-connect/token \
  -u "agent-service:$AGENT_CLIENT_SECRET" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
  -d "subject_token=$USER_TOKEN" \
  -d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
  -d "requested_token_type=urn:ietf:params:oauth:token-type:access_token" \
  -d "scope=calendar:read"

Two fields do the real work. subject_token is who the new token acts for — the user. scope=calendar:read is the downscope: the authorization server will never issue a token broader than the subject token permits, and by naming only calendar:read you deliberately hand the next hop the minimum it needs. If the agent held calendar:read calendar:write billing:*, the exchanged token can be made to carry only calendar:read. You get back a normal token response:

{
  "access_token": "eyJhbGciOi...",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 300
}

Note expires_in: 300 — five minutes. Short-lived by construction, so a leaked exchanged token is a five-minute problem, not a standing one.

3. Read the delegation trail#

Decode the returned access token and you'll find an act (actor) claim recording the delegation:

{
  "sub": "user-6f3a",          // still acting for the user
  "scope": "calendar:read",     // downscoped
  "act": { "sub": "agent-service" },   // the agent is the actor
  "exp": 1785405600
}

This is the difference between impersonation and delegation, and it's the whole reason to prefer delegation for agents: the token doesn't just look like the user, it says agent-service is acting for user-6f3a. Your downstream service — and your audit log — can see the deputy by name. RFC 8693 is explicit that the act claim is nestable, which sets up the next step.

4. Re-exchange on every hop (identity chaining)#

The mistake is exchanging once and then forwarding that token everywhere it goes. Don't. Each boundary the request crosses deserves its own credential, minted for that hop and no other. When the agent calls a second service, it exchanges again — this time using the token it currently holds as the new subject_token, and requesting a still-narrower scope aimed at that specific downstream service and nothing beyond it:

$ curl -s -X POST \
  https://kc.example.com/realms/myrealm/protocol/openid-connect/token \
  -u "agent-service:$AGENT_CLIENT_SECRET" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
  -d "subject_token=$SCOPED_TOKEN" \
  -d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
  -d "audience=calendar-service" \
  -d "scope=calendar:read"

Keycloak 26.5 (January 2026) shipped identity chaining precisely for this: each exchange narrows scope and nests another act claim, so a two-hop call produces a token whose actor is tool → agent → user, a complete, readable audit trail of how the request propagated. The scope only ever shrinks; it can't grow back. That's the invariant worth protecting.

The three ways this goes wrong#

None of this is new cryptography. It's one endpoint, one grant type, and the discipline to call it at every boundary instead of once. If you want the reasoning behind why the seam between workload identity and delegated identity is where agents leak authority, that's the companion piece — and if you're wondering why every identity-security startup is suddenly raising on exactly this problem, it's because 79 of every 109 machine identities in the enterprise is now an agent, and each one needs a token that expires.