Token Exchange
Token exchange, defined by
RFC 8693, instead takes a token
the caller already holds — the subject_token — and issues a new token
derived from it. subject_token and subject_token_type are required on
every request; there is no defined way to invoke this grant without
presenting an existing credential.
Exactly one of two outcomes results, determined by whether actor_token
is also present:
- Downscoping (no
actor_token): the new token is issued for the same subject assubject_token, with a scope equal to or narrower than the original. This is typically used before passing a token to code that should not be able to exceed one specific task. - Delegation (
actor_tokenandactor_token_typepresent): the new token is issued for the same subject, and additionally records that a second party — the actor — is currently acting on the subject's behalf. The subject retains ownership of the token; only the fact that another party is acting on it is recorded. For a worked example of delegation — an AI agent redeeming a human's mandate — see Authenticating AI Agents with O2ID.
Downscoping:
Caller ──(already has)──► subject_token
Caller ──POST /oauth2/token (subject_token + optional scope)──► new, narrower token
Delegation:
Actor ──(already has, e.g. via client_credentials)──► actor_token
Caller ──POST /oauth2/token (subject_token + actor_token)──► new, delegated token
Token Request
POST /oauth2/token
Content type: application/x-www-form-urlencoded
Always required:
| Field | Value |
|---|---|
grant_type | urn:ietf:params:oauth:grant-type:token-exchange — identifies the request as token exchange, as distinct from authorization_code or client_credentials |
client_id | A registered application's clientId. As with every other grant, the application's allowedScopes apply as an outer ceiling: this grant cannot return more than the requesting application was itself ever allowed to have |
subject_token | The access token presented as proof of the caller's current identity. The new token is issued for this identity |
subject_token_type | Must be the literal string urn:ietf:params:oauth:token-type:access_token. RFC 8693 defines several possible token types (ID tokens, SAML assertions, and others); O2ID accepts only an access token here, so this field exists but has a single valid value |
Optional:
| Field | Value |
|---|---|
scope | A space-delimited list of the scopes requested for the new token. If omitted, the new token receives the full intersection of every applicable ceiling, described below |
actor_token | An access token identifying the second party currently acting on the subject's behalf. Present only for delegation — see Actor-token delegation. actor_token_type is required whenever actor_token is present, and neither may be sent without the other |
actor_token_type | Must be urn:ietf:params:oauth:token-type:access_token, required exactly when actor_token is present; both are omitted for plain downscoping |
resource / audience | Records the downstream service the caller intends to use this token with, as either an absolute URI (resource) or a free-form name (audience). Both are validated and recorded on the minted token — see resource / audience |
Rejected outright, not silently ignored: a requested_token_type
other than access_token. RFC 8693 permits a caller to request a
different token type than the one it started with — for example,
exchanging a SAML assertion for a JWT. O2ID does not support this: the
grant issues only access tokens, and a request for any other type is
treated as a request error.
Success response — the same overall shape returned by any other grant, with one field added and two omitted:
{
"access_token": "…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "users:read",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token"
}
issued_token_type is present because RFC 8693 §2.2.1 requires every
token-exchange response to state the type of token issued (always
access_token here). A refresh_token or id_token is never returned:
a caller requiring an additional token later simply invokes this grant
again with a current subject_token. Re-exchanging on each request means
scope is re-derived from the current state on every call and, for
delegation, the mandate's revocation and expiry status is re-checked each
time, rather than relying on a refresh token whose validity was
established only once, at the time of the original exchange.
Downscoping
Downscoping is the simpler of the two outcomes. A caller supplies only
subject_token — no actor_token — and receives a new token for the
same identity with a smaller set of scopes. A typical use case: before
invoking third-party or lower-trust code with a token, exchange it for
one that cannot do more than the single task at hand, regardless of what
the original token was capable of.
The scope returned is the intersection of three inputs:
- The front-door application's
allowedScopes(the application-level ceiling every grant respects) - The subject's current, live role scopes, evaluated at the moment of exchange rather than cached from when the original token was issued
- The scopes requested in
scope, if provided — omittingscopeentirely returns the full intersection of the two ceilings above
The second input has an immediate consequence: because role scopes are evaluated live on every exchange, revoking a role takes effect on the subject's very next exchange, not when the existing token happens to expire. This matches the live-recheck behavior of every other O2ID token; token exchange is not an exception.
curl -s -X POST http://localhost:8080/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
--data-urlencode "client_id=${CLIENT_ID}" \
--data-urlencode "subject_token=${SUBJECT_TOKEN}" \
--data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
--data-urlencode "scope=users:read"
Actor-token delegation
Delegation occurs when actor_token is also present, together with
actor_token_type. RFC 8693 §4.1's delegation rules apply in this case.
The subject of the new token is unchanged: it remains whoever
subject_token identified, never the actor. The party identified by
actor_token is instead recorded separately, in an act claim internal
to the token. Delegation therefore adds a record of who is currently
acting, rather than replacing the record of whose authority is being
exercised.
Keeping the subject fixed is what auditability depends on. If the subject were permitted to change with each delegation, a token's history would be unrecoverable beyond the most recent actor — the party that originally authorized the underlying action would be lost. With the subject fixed, every delegated action is traceable to the party that authorized it, regardless of how many actors the token subsequently passes through.
O2ID applies this delegation mechanism to one concrete case: an AI agent redeeming a human's mandate. A mandate is a record stating that a specific AI agent may act as a given human, with specified scopes, until a specified time. It need not predate the exchange by any particular margin: a mandate can be created and redeemed in the same interaction, when the human is present and authorizing exactly this action, or set up well ahead of an agent acting unattended — see Managing AI Agents for both patterns. To redeem a mandate:
- The AI agent presents its own token as
actor_token. This token is obtained separately, in advance, via the Client Credentials Grant, proving the agent's own identity independently of any human or mandate. - The human presents their own current access token as
subject_token.
No mandate_id parameter exists in the request — RFC 8693 defines no
such concept — so the mandate is instead resolved implicitly, by matching
the (AI agent, human) pair identified by actor_token and
subject_token. If no active mandate authorizes that pair, the exchange
fails with invalid_grant.
curl -s -X POST http://localhost:8080/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
--data-urlencode "client_id=${CLIENT_ID}" \
--data-urlencode "subject_token=${HUMAN_TOKEN}" \
--data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
--data-urlencode "actor_token=${AGENT_TOKEN}" \
--data-urlencode "actor_token_type=urn:ietf:params:oauth:token-type:access_token"
The scope granted is the intersection, evaluated at the moment of exchange, of:
- The front-door application's
allowedScopes - The acting AI agent's own
allowedScopes— what the agent itself was permitted, independent of the human's or the mandate's permissions - The mandate's
scope, fixed at creation time: it can only narrow what the creating human was able to grant then, and can never be widened later without a new mandate - The human's current, live role scopes, re-evaluated on every exchange as with downscoping
The fourth input has the same consequence as in downscoping: revoking the human's role immediately revokes every AI agent acting on their behalf, at each agent's next exchange, with no separate step required.
Chaining
A subject_token presented to this grant need not be an undelegated
token: it may itself already be the result of an earlier delegation. In
that case, its existing actor is not discarded or replaced; the new actor
is nested inside it, as act.act, and so on for additional hops.
Repeated re-delegation builds a chain of custody, in which each hop
records the party that acted at that point, while the original human
remains at the root as the token's unchanging subject.
Each hop in the chain requires its own, independently granted mandate. O2ID does not permit an actor to be trusted implicitly on the basis that an earlier hop in the chain was authorized: every actor at every position in the chain must be authorized directly by the human, rather than by transitive extension of a delegation the human never reviewed.
resource / audience
resource (an absolute URI, for example https://api.example.com) or
audience (a free-form name) records the downstream
resource server the caller intends to use
the newly minted token with. If resource is supplied, it is validated
as a well-formed absolute URI; a malformed value is rejected with
invalid_target, per RFC 8707 §2. Whichever value is supplied is
recorded on the minted token and can be read back later, for example via
token introspection.
Supplying either one at all is optional — omit both entirely for plain
downscoping with no audience restriction. But once supplied, the target
must be a currently
registered resource server that the
calling application holds some standing
authorization
for; anything else — an unregistered value, or one the application was
never authorized for — is rejected with invalid_target.
That check only confirms the calling application has standing for the
named target; it deliberately does not change how the token's own scope
is computed, which stays exactly what it already was — derived from the
human's live RBAC role scopes (see Managing Roles),
narrowed by the application's allowedScopes and, for delegation, the
agent's own scopes and the redeemed mandate. Token exchange's scope is
fundamentally about a human's own permissions carried or narrowed
forward, a different authorization model from a resource server's own
scope namespace. Getting a Token for a Resource Server
is where a resource server's own scopes actually govern what's granted —
there, an audience produces a token scoped to that resource server's
authorized permissions instead.
For how to inspect what a minted token actually carries, see Token Introspection.
API Reference
See the API Reference for the full request/response schemas.