Chain of Agency
When agents act on behalf of users, or delegate to other agents, LumoAuth creates a verifiable chain of identity. This is the delegation chain: a record of which user asked which agent to act, and which agent called which. Delegation uses token exchange: a client swaps one token for another. The exchange records a subject token (what the caller holds — usually the user) and an actor token (who is doing the acting — usually the agent). The resulting access token records the full call chain in nested act claims, so every downstream action is traceable.
Understanding delegation
Delegation answers the question: "Who is really behind this action?"
curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=USER_ACCESS_TOKEN" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "actor_token=AGENT_ACCESS_TOKEN" \
-d "actor_token_type=urn:ietf:params:oauth:token-type:access_token"
Response
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token"
}
Sender-constrained exchange (DPoP)
A token exchange can be sender-constrained with DPoP (a proof that ties the token to the client's key, so a stolen copy cannot be replayed). Include a DPoP proof header (htm=POST, htu = the token endpoint), and the issued token is bound to the proof key. JWT tokens carry cnf.jkt; opaque tokens are bound server-side. The response then advertises "token_type": "DPoP" (plus a DPoP-Nonce header when nonces are enabled). Clients registered with dpop_bound_access_tokens must send a proof. An invalid one fails with invalid_dpop_proof, and organizations that require server nonces answer a nonce-less proof with use_dpop_nonce. When the resource parameter names a registered MCP server with Require DPoP on, an exchange without a valid proof is refused with invalid_dpop_proof. A delegated token for that server is therefore always sender-constrained.
The actor claim
The resulting JWT contains an act (actor) claim that captures the delegation chain, shaped per the OAuth Actor Profile (draft-mcguinness-oauth-actor-profile):
{
"iss": "https://app.lumoauth.dev/orgs/acme/api/v1",
"sub": "user:alice",
"act": {
"sub": "agent_research-bot",
"iss": "https://app.lumoauth.dev/orgs/acme/api/v1",
"sub_profile": "ai_agent",
"name": "Research Bot"
},
"scope": "read:documents write:reports",
"exp": 1704067200,
"iat": 1704063600
}
This token says: "research-bot is acting as alice". Every actor object carries:
| Claim | Meaning |
|---|---|
sub | The actor's subject identifier (unchanged from earlier releases: user id, or agent_<id>). |
iss | The namespace that act.sub is scoped to — this authorization server. For an ID-JAG redeemed from an external IdP it is that IdP's issuer. |
sub_profile | Entity profile of the actor: user, ai_agent (agents) or workload (an OAuth client / upstream application). |
Discovery advertises the classifications in use as entity_profiles_supported: ["user", "ai_agent", "workload"].
Nested delegation
Agents can delegate to other agents, building a chain of actors:
{
"sub": "user:alice",
"act": {
"sub": "agent:orchestrator",
"act": {
"sub": "agent:search-tool",
"act": {
"sub": "agent:web-scraper"
}
}
}
}
Reading bottom-up: "web-scraper, called by search-tool, called by orchestrator, acting for alice".
Nested act chains like this are produced by the AAuth agent-token exchange request type (see
AAuth Protocol — the protocol for agent identity, based on signed requests). The plain /oauth/token token exchange below records a single flat
actor level per exchange.
act chain goesThe /oauth/token token exchange writes a single, flat act claim ({ "sub": "<actor>" }).
It does not nest or carry forward an existing act, so a single exchange produces one actor level.
Multi-level nesting like the example above is produced by the AAuth agent-token exchange request type
(see AAuth Protocol), which chains act claims. Neither path enforces a fixed maximum depth.
Each exchange can still only narrow scopes, never widen them.
Implementation example
The flow has two steps. First, authenticate the agent with OAuth 2.0
client-credentials; the agent must have May act on behalf of users switched
on in its registry entry. Then exchange the user's token plus the agent's token
for a delegated token, and call the API with it. All requests hit the same token endpoint,
POST /orgs/{orgId}/api/v1/oauth/token.
import os, requests
BASE = os.environ["LUMOAUTH_URL"] # https://app.lumoauth.dev
ORG = os.environ["LUMOAUTH_ORG_ID"] # acme-corp
TOKEN = f"{BASE}/orgs/{ORG}/api/v1/oauth/token"
ACCESS = "urn:ietf:params:oauth:token-type:access_token"
# 1. Authenticate the agent (client credentials). There is no special
# delegation scope — the agent's registry flag "Can act on behalf of
# users" is what authorizes step 2.
agent_token = requests.post(TOKEN, data={
"grant_type": "client_credentials",
"client_id": os.environ["AGENT_CLIENT_ID"],
"client_secret": os.environ["AGENT_CLIENT_SECRET"],
"scope": "read:documents",
}).json()["access_token"]
# 2. RFC 8693 token exchange:
# subject_token = the user (who we act FOR), actor_token = the agent.
# An optional `scope` may only narrow the result, never widen it.
r = requests.post(TOKEN, data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": user_access_token, # obtained via normal user login/consent
"subject_token_type": ACCESS,
"actor_token": agent_token,
"actor_token_type": ACCESS,
"scope": "read:documents", # optional scope reduction
})
r.raise_for_status() # 400 invalid_grant (bad subject/actor token); 403 access_denied = agent may not act on behalf of users
delegated_token = r.json()["access_token"]
# 3. Call the API on the user's behalf — the delegated token carries the `act` chain.
docs = requests.get(
f"{BASE}/orgs/{ORG}/api/v1/documents",
headers={"Authorization": f"Bearer {delegated_token}"},
)
For agent-to-agent delegation, feed the delegated token back in as the
subject_token of a second exchange with the sub-agent as the new actor_token.
The AAuth exchange request type nests the act claims (see the note above).
To tear down a delegation, revoke the user's refresh token at
POST /orgs/{orgId}/api/v1/oauth/revoke (token_type_hint=refresh_token),
which invalidates the related tokens.
Audit trail
All actions performed with delegated tokens are logged with the complete chain:
{
"timestamp": "2024-01-01T12:34:56Z",
"action": "document.create",
"resource": "document:q4-analysis",
"principal": {
"subject": "user:alice",
"actor_chain": [
"agent:research-bot"
]
},
"result": "allowed",
"ip_address": "10.0.1.50",
"user_agent": "LumoAuth-Agent/1.0"
}
Crossing application boundaries (ID-JAG)
The act chain above covers delegation within one authorization domain.
When an agent or AI application needs to reach another app's resources
(an MCP server or SaaS API fronted by its own authorization server), use
Cross App Access (ID-JAG). The user's IdP mints an
identity assertion (requested_token_type=id-jag at the token-exchange
endpoint), and the target app's authorization server redeems it without a
second user consent step. The delegation context is preserved across the hop.
Transaction tokens
For propagating a delegated identity inside your own service mesh, the
same token-exchange endpoint can mint short-lived transaction tokens
(draft-ietf-oauth-transaction-tokens). An authenticated workload (client or
agent) exchanges an inbound token with
requested_token_type=urn:ietf:params:oauth:token-type:txn_token for a
txn_token+jwt. That token carries the transaction context (azd
authorization-details, optional purp purpose) down the internal call chain.
The requester may only narrow azd.scope relative to the subject token —
same rule as act-chain exchanges. Opt-in per organization via
authSettings.txn_tokens_enabled (default off).
Access control for delegation
Not every agent can delegate for every user. The token-exchange grant enforces, at the token endpoint and with no interactive step:
- Agent flag — the agent presented as
actor_token(or authenticating the exchange itself) must have May act on behalf of users switched on in the Agent Registry. There is nodelegate:on_behalfscope. If the flag is off the exchange fails with403 access_denied("Agent is not authorized to act on behalf of users") and anoauth.token_exchangeaudit failure is written. - Valid subject —
subject_tokenmust be a live LumoAuth access, JWT or ID token resolving to a user (or agent) in the same organization. The audience of a client-initiated exchange is bound to the calling client, so a token minted for another application cannot be exchanged. Otherwise400 invalid_grant. In the agent-authenticated (on-behalf-of) form a refresh token is not accepted assubject_token(400 invalid_request). It is a credential for the user's own application, not a statement of what the user authorized. An optional RFC 8707resourceparameter becomes the delegated token'saud; without one the token is audience-bound to the acting agent. - Actor must match — in the agent-authenticated form the
actor_tokenmust resolve to the same agent that authenticated the request. - Scope attenuation — the requested
scopeis intersected with the agent's registered scopes and with the subject token's own scopes. An exchange can therefore only narrow, never widen, what the user already granted. - Budget — the agent's budget policy is checked before the exchange runs
(
429 access_deniedwhen exceeded) and usage is recorded afterwards.
The user's authorization is whatever the subject_token already carries — the
user consented when that token was issued through the normal login / consent
flow. The exchange itself never redirects the user anywhere. If you need a
human decision for a specific action, use JIT permissions (just-in-time: a short-lived permission that a human approves) or
async authorization (CIBA). Both pause for approval
before a token is issued.
Related
- Agent Registry — register the agent and switch on May act on behalf of users
- AAuth Protocol — delegation with cryptographic proof-of-possession
- Cross App Access (ID-JAG) — carry a delegated identity across application boundaries
- Workload Federation — the identity-side use of the same token exchange
Standards used on this page
| Plain name | Standard |
|---|---|
| Token exchange (delegation) | RFC 8693 |
| Sender-constrained tokens (DPoP) | RFC 9449 |
See the standards reference for what each one covers and how LumoAuth uses it.