Obexal Docs

Docs/AI agents/Delegation (Token Exchange)

Delegation with Token Exchange

An agent acts on behalf of a user through RFC 8693 Token Exchange: the delegated token keeps the user as subject, attributes the agent in the act claim, and can only carry downscoped permissions.

The core question of agent security is "who is really acting". Obexal answers it with OAuth Token Exchange (RFC 8693): the agent trades a user's access token for a delegated token in which the user remains the subject and the agent is recorded as the actor. Downstream APIs see both identities, always.

Acting on behalf of a user

A client_credentials token says "the agent, for itself". That is wrong for user-facing work: the agent would act with its own blanket permissions, and nothing downstream would tie the action to the user who requested it. With Token Exchange, the agent presents the user's access token as proof of the delegation context and receives a new token that acts as the user, attributed to the agent, with permissions that can only shrink.

The exchange request

The exchange is a regular call to POST /oauth/token. The agent must be a confidential client, authenticate itself (HTTP Basic here), and carry the token-exchange grant, otherwise the request is refused.

Form parameterRequiredValue
grant_typeyesurn:ietf:params:oauth:grant-type:token-exchange
subject_tokenyesThe user's Obexal access token (JWT at+jwt)
subject_token_typenourn:ietf:params:oauth:token-type:access_token, the only supported value
requested_token_typenourn:ietf:params:oauth:token-type:access_token, the only supported value
scopenoRequested subset; defaults to the subject token's scopes
resourcenoAbsolute URI of the target resource server (RFC 8707); mandatory if the agent has an audience allowlist
curl -sS -X POST https://accounts.obexal.com/oauth/token \
  -u "8Zl2vQx0T9hK3mW1s5nDgA:$AGENT_CLIENT_SECRET" \
  -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 scope="tickets:read" \
  -d resource="https://api.example.eu/tickets"
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6IjIwMjYtMDYifQ...",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 600,
  "scope": "tickets:read"
}

Replace accounts.obexal.com with your custom domain if your organization uses one.

The delegated token: sub and act

The decoded payload of the delegated access token:

{
  "iss": "https://accounts.obexal.com",
  "sub": "b3f1c9d2-5a77-4e10-9c58-2f4a6d8e1b90",
  "act": { "sub": "8Zl2vQx0T9hK3mW1s5nDgA" },
  "aud": "https://api.example.eu/tickets",
  "client_id": "8Zl2vQx0T9hK3mW1s5nDgA",
  "scope": "tickets:read",
  "tenant": "7c2d1e0f-4b9a-4c3d-8e5f-6a7b8c9d0e1f",
  "iat": 1782981000,
  "exp": 1782981600
}

sub is the user: authorization decisions downstream apply to them. act.sub is the agent: attribution is never lost. If the subject token was itself a delegated token (an agent re-delegating to another agent), the previous actor is nested: "act": {"sub": "agent-B", "act": {"sub": "agent-A"}}. The whole chain stays readable. Token introspection (RFC 7662) exposes the same act claim, so resource servers get the attribution whether they validate locally or introspect.

Downscoping, never escalation

The scopes of the delegated token are the intersection of every constraint in play:

  • the requested scope must be a subset of the subject token's scopes, otherwise invalid_scope;
  • the result is intersected with the agent's scopes, then with the policy's scope ceiling, then, for a governed agent, with the scopes the user authorized;
  • if nothing survives the intersections, the exchange fails with invalid_scope.

An agent can therefore never obtain through a user more than the user has, more than it is itself allowed, or more than its governance permits.

Audience binding (RFC 8707)

Without resource, the delegated token's aud is the agent's client_id. With resource, the token is bound to that resource server: an API that checks aud (as it should) will reject the token anywhere else. The value must be an absolute URI without fragment, otherwise invalid_target. If the agent's policy defines an audience allowlist, resource becomes mandatory and must belong to the list (compared in canonical form), otherwise invalid_target.

User authorization for governed agents

A governed agent (created with requireConsent: true) cannot act on behalf of a user who has not authorized it: the exchange fails with invalid_grant. Users manage these authorizations themselves, through a session-authenticated self-service API (used by the account portal or your own frontend):

Method and pathEffect
GET /v1/agent-authorizationsList the agents this user authorized, with the granted scopes
POST /v1/agent-authorizationsAuthorize an agent: {"agentClientId": "...", "scopes": ["tickets:read"]}
DELETE /v1/agent-authorizations/{clientId}Revoke the authorization (idempotent)

The authorized scopes must be a subset of the agent's scopes, and they bound every future delegation for that user. Trusted first-party agents (created without requireConsent) skip this step.

Guarantees and limits

  • Tenant isolation: the subject token must belong to the agent's organization, otherwise invalid_grant.
  • Human subjects only: the subject must be an active user; a machine token (sub equal to client_id) is refused as subject.
  • Short-lived, no refresh: the delegated token lives 10 minutes by default (the policy can cap it lower) and no refresh token is issued; the agent re-exchanges while the user's token is valid.
  • Audited: every exchange writes an oauth.token.exchange event recording who delegated to which agent, for which audience and scopes; see the delegation audit trail.
  • Revocable: the kill switch refuses new exchanges and makes the agent's tokens report as inactive at introspection.