Delegation and Token Exchange#
When CPEX forwards an operation to a backend, the backend needs a credential. Forwarding the caller’s inbound token is usually wrong: it is scoped for the agent, not the backend, and it carries more privilege than the operation needs. Delegation mints a fresh, narrowly scoped credential for the specific downstream call.
The requirement#
The scenario’s get_compensation reads from a backend HR system that expects its own audience-scoped token with only the read_compensation scope. The caller never holds that token. CPEX must exchange the caller’s verified identity for a downstream credential, scoped down to exactly what the operation needs, and only after authorization has passed.
Delegation as an effect#
delegate is an effect in the authorization.pre_invocation phase. It names a delegator plugin and the target it mints for:
authorization:
pre_invocation:
- "require(role.hr)"
- "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])"
- "delegation.granted.permissions contains 'read_compensation': allow"The order matters. The require gate runs first, so a credential is only minted for a caller who passed authorization. After the exchange, a post-check verifies the credential actually carries the scope requested, and denies the operation if the IdP returned less.

The delegator plugin#
The bundled delegator/oauth plugin performs RFC 8693 token exchange against an IdP token endpoint:
plugins:
- name: workday-oauth
kind: delegator/oauth
hooks: [token.delegate]
config:
token_endpoint: "https://idp.example.com/realms/agents/protocol/openid-connect/token"
client_id: "cpex-gateway"
client_secret_source:
kind: file
path: /etc/cpex-secrets/client-secret
default_outbound_header: "Authorization"It exchanges the caller’s inbound token for one scoped to audience with the requested permissions, and attaches it to the outbound request. The result populates delegation attributes that later rules read.
Choosing who the exchange is for#
By default a delegate step exchanges the user’s token: the minted credential speaks for the user, on-behalf-of style. Two step keys change that:
| Key | Meaning | Accepts | Default |
|---|---|---|---|
subject | Whose identity the minted token speaks for. | user, client, caller_workload, this_workload | user |
actor | An additional credential recorded as the RFC 8693 actor_token, naming who is acting. | user, client, caller_workload | none |
actor accepts only inbound credentials, because an actor is by definition a party that presented itself to us. subject additionally accepts this_workload — us — which is the one principal with no inbound credential at all. (gateway is accepted as a deprecated alias of this_workload, from before CPEX was decoupled from the gateway shape.)
On-behalf-of a user, with the agent named#
The common agentic shape: a user asked for something, and an agent is carrying it out. The user is the subject; the calling agent’s SVID rides along as the actor, so CPEX asks the token service to record act alongside sub, letting the backend see both parties.
Whether act lands in the minted token is the token service’s call: RFC 8693 distinguishes impersonation (subject only, no act) from delegation (act records the actor), and only a service implementing the delegation path emits it. CPEX always sends the delegation request. Notably, Keycloak’s Standard Token Exchange (v2) implements impersonation only and silently ignores actor_token, so no act appears there — capture the actor at the CPEX boundary (audit / downstream header) if your token service doesn’t support delegation.
pre_invocation:
- "delegate(workday-oauth, target: workday-api, subject: user, actor: caller_workload,
audience: workday-api, permissions: [read_compensation])"An agent acting on its own#
A scheduled or background agent with no user in the loop exchanges its own SVID:
pre_invocation:
- "delegate(workday-oauth, target: workday-api, subject: caller_workload,
audience: workday-api, permissions: [read_compensation])"This requires a role: caller_workload identity resolver to have populated the SVID slot. With no workload credential present the exchange has no subject token and is denied rather than silently falling back.
The enforcement point calling as itself#
A common deployment (an MCP gateway is one shape): agents authenticate to CPEX, but this instance is the one holding access to the backend tools. The agent never possesses a credential the backend would accept.
pre_invocation:
- "require(role.hr)"
- "delegate(workday-oauth, target: workday-api, subject: this_workload,
audience: workday-api, permissions: [read_compensation])"subject: this_workload has no inbound credential to exchange, so the delegator switches from RFC 8693 token exchange to an RFC 6749 §4.4 client_credentials grant: no subject_token is sent, and this instance’s identity is the OAuth client identity it already authenticates with. Nothing extra to configure — the delegator’s existing client_id / client_secret is this instance’s identity.
Two consequences worth understanding before choosing this shape:
- This instance is the only enforcement point. The backend sees a token that says “this instance” and has no idea which agent triggered the call. Whatever the
requiregates allow is what happens; there is no second opinion downstream. Gate accordingly. - The minted token cannot name the calling agent.
actor_tokenis a token-exchange parameter with no meaning underclient_credentials, so it is not sent even if the step asks for one. Attribution to the calling agent lives in your audit log, not in the credential. Carrying the agent inside the token requires this instance to have a real subject credential of its own to exchange — its own SVID — which is not yet wired up.
Who the minted token speaks for#
Each exchange is attributed to exactly one principal, and the attribution is derived from subject rather than declared:
subject | Attribution | Speaks for |
|---|---|---|
user (default) | on_behalf_of_user | The end user |
client | on_behalf_of_user | The brokering application |
caller_workload | as_caller_workload | The calling agent |
this_workload | as_this_workload | This instance itself |
There is deliberately no mode key. If routes could declare the attribution independently of the credential they hand over, a route could claim to act on behalf of a user while exchanging a workload SVID. Deriving it means the operator states one thing — which credential — and the consequence follows.
The attribution is load-bearing beyond the audit trail: minted credentials are cached per (subject, workload, audience, scopes, attribution), so tokens minted for one calling agent are never served to another.
Delegation attributes#
After a delegate effect, policy can read the outcome and the delegation context:
| Attribute | Meaning |
|---|---|
delegation.granted.permissions | Scopes the IdP actually granted on the minted token. |
delegation.depth | How many delegations deep this request is. |
delegated | True when the request is acting under a delegated credential. |
delegation.origin_subject_id | The original subject at the head of the chain. |
delegation.actor_subject_id | The acting subject for this hop. |
These let policy reason about the chain itself, for example require(delegation.depth <= 1) to refuse deeply nested delegation, or the post-check above to enforce least privilege on what was actually granted.
How it connects to the pipeline#
delegate dispatches to a plugin implementing the token.delegate hook. The minted credential is recorded in the request’s delegation context and the audit log. Because delegation is an explicit effect rather than a side effect of forwarding, it is sequenced like any other effect: gated behind authorization, followed by verification, and halted on error when configured with on_error: deny.