Skip to content

Use case — Token Exchange (RFC 8693) ​

Token exchange (RFC 8693) lets a client trade a token it holds for a new one with a different audience, a narrower scope, or a recorded delegation. The usual case: a front-end service holds Alice's access token and needs a token to call a downstream service on her behalf. For the concepts, including impersonation vs delegation and why cnf is rebound, read the Token Exchange primer first. This page covers the wiring.

subject_token vs actor_token — what's the difference?

subject_token is the token for the user (or service) the new token is issued on behalf of; the issued token's sub comes from it. actor_token is optional and names the party that acts for the subject. In "service-a calls service-b on alice's behalf", alice's access token is the subject_token, and service-a's own access token may be the actor_token. Without an actor_token, the authenticated calling client is the actor. A request whose actor_token resolves to the same subject as the subject_token is rejected with 400 invalid_request.

act chain — what's that?

RFC 8693 §4.1 defines the act claim as a record of "who acted on whose behalf". On the issued token, act.sub names the actor. If the subject_token already carried an act, it is nested inside the new entry, which produces a chain (alice → service-a → service-b). A resource server walks act to check the whole chain: "transfers may be initiated by alice via service-a, but not by bob via service-a". The OP refuses a chain deeper than five levels, before your policy runs.

The big picture ​

Token exchange does not copy the incoming token to another audience. The OP verifies the subject and the actor, narrows scope and audience, builds the act chain and rebinds sender constraint to the caller's key before it issues a new access token.

Two tokens in, one token out
Token exchange end to end. Service A presents Alice's token as the subject and its own token as the actor. The OP validates both, intersects the scopes, canonicalises the audience, rebuilds the act chain and the confirmation claim, and runs the policy before issuing a token addressed to Service B.subject_tokenAlice's access tokenscope=profile writeactor_tokenservice-a's own tokensub=service-aOP /tokengrant_type=…:token-exchange1validate both tokens2intersect the scopes3canonicalise the audience4rebuild act and cnf5run Policy.Allowany step may refuseThe issued tokenaud=service-bact.sub=service-aservice-bverifies act and cnf,then decides access
The exchange only narrows — scopes intersect, the audience is one service, the actor is recorded — so no request returns more authority than the subject token carried. Step 5 is where your policy lives; the OP never decides on its own whether service A may act for Alice against service B.

The issued token's cnf comes from the DPoP or mTLS proof presented on this exchange request, never from subject_token.cnf. A subject_token or actor_token that is itself sender-bound must be presented with the same proof, or the exchange fails with invalid_grant.

Enabling the grant ​

go
import (
  "github.com/libraz/go-oidc-provider/op"
  "github.com/libraz/go-oidc-provider/op/grant"
  "github.com/libraz/go-oidc-provider/op/storeadapter/inmem"
)

st := inmem.New()

provider, err := op.New(
  op.WithIssuer("https://op.example.com"),
  op.WithStore(st),
  // Token exchange resolves end users, and there is no PrimaryPassword to
  // infer the user store from, so name it.
  op.WithUserStore(st.Users()),
  op.WithKeyset(myKeyset),
  op.WithCookieKeys(myCookieKey),

  // Token exchange is an extension grant, so WithGrants only chooses the
  // built-in grants this OP also serves. RefreshToken is listed because
  // the policy below may opt into refresh issuance.
  op.WithGrants(grant.ClientCredentials, grant.RefreshToken),
  op.WithTokenExchangeGrant(op.TokenExchangeGrant{Policy: myPolicy}),

  op.WithStaticClients(op.ConfidentialClient{
    ID:         "service-a",
    Secret:     serviceASecret,
    AuthMethod: op.AuthClientSecretBasic,
    // The client must list the token-exchange URN.
    GrantTypes: []string{op.TokenExchangeGrantType},
    Scopes:     []string{"profile", "write:transfer"},
    // Every audience the client may exchange for must be registered.
    Resources:  []string{"https://api.b.example.com"},
  }),
)

op.WithTokenExchangeGrant(...) does three things:

  1. Registers the RFC 8693 URN (urn:ietf:params:oauth:grant-type:token-exchange) at /token.
  2. Wires the built-in handler that resolves subject_token / actor_token, normalises audiences, bounds scope, builds the act chain, rebinds cnf, and clamps TTL.
  3. Stores your TokenExchangePolicy for per-request admission.

Policy is required, deny by default

A nil TokenExchangePolicy fails at op.New (op.ErrTokenExchangePolicyNil). The grant has no safe default — every exchange must be evaluated by deployment policy. Calling WithTokenExchangeGrant twice fails with op.ErrTokenExchangeDuplicate.

Implementing TokenExchangePolicy ​

The policy interface is small but decisive. It receives a resolved TokenExchangeRequest (tokens verified, audience normalised, scope bounded) and returns either a *TokenExchangeDecision (with optional narrowing overrides) or an error.

go
type myPolicy struct{ /* dependencies */ }

func (p *myPolicy) Allow(ctx context.Context, req op.TokenExchangeRequest) (*op.TokenExchangeDecision, error) {
    // Deny unknown audiences.
    for _, aud := range req.RequestedAudience {
        if !p.audienceAllowed(req.Client.ID, aud) {
            return nil, &op.Error{Code: "invalid_target", Description: "audience not allowed for this client"}
        }
    }

    // Refuse delegation from unbound subject tokens onto sender-bound audiences.
    if req.Actor != nil && req.SubjectToken.Confirmation == nil && p.audienceRequiresProof(req.RequestedAudience) {
        return nil, &op.Error{Code: "invalid_grant", Description: "downstream requires sender-bound subject"}
    }

    // Optionally opt into refresh-token issuance for long-lived service chains.
    if req.Actor != nil && p.actorEligibleForRefresh(req.Actor) {
        return &op.TokenExchangeDecision{
            IssueRefreshToken: op.PtrBool(true),
        }, nil
    }
    return nil, nil // accept with provider defaults
}

Decision rules ​

FieldBehaviour
GrantedScopeWhen non-empty, replaces the provider-computed set. MUST be a subset of req.RequestedScope; broader values yield invalid_scope.
GrantedAudienceWhen non-empty, replaces the provider-computed set. MUST be a subset of req.RequestedAudience; broader values yield invalid_target.
GrantedTTLWhen positive, narrows the issued access-token lifetime below the provider-computed cap. A larger value is truncated to the cap and recorded as token_exchange.ttl_capped. Zero or negative keeps the computed lifetime.
IssueIDToken*bool override. The default is true when subject_token was an id_token and false otherwise. An explicit false suppresses the ID Token even when openid remains in the granted scope.
IssueRefreshToken*bool override. The default (nil) issues no refresh token. To opt in, set op.PtrBool(true); the pointer shape keeps "unset" distinct from "false".
ExtraClaimsMerged into the access token and, when one is issued, the id_token. Reserved names (iss / sub / aud / iat / exp / auth_time / nonce / acr / amr / azp / at_hash / c_hash / sid / client_id / jti / scope / gid / authorization_details / userinfo_claims / act / cnf) are dropped silently, without failing the exchange, so the policy cannot rewrite the act chain, hijack sub, or forge cnf.

The OP applies the subset rule again after the decision. A policy that returns a broader scope or audience is rejected with invalid_scope / invalid_target and an audit event; no widened token is minted. An empty granted scope is invalid_scope too.

Error semantics ​

ReturnWire response
(nil, nil)Provider defaults; exchange admitted.
(*Decision, nil)Decision applied (may narrow); exchange admitted.
(_, *op.Error)Returned verbatim — pick the spec error code (invalid_grant, invalid_target, invalid_scope, unauthorized_client).
(_, any other error)Collapses to invalid_grant; original cause is logged but not exposed.

What the OP enforces before your policy runs ​

The policy can narrow the result but never widen it:

  • Token checks: subject_token_type and actor_token_type must be one of urn:ietf:params:oauth:token-type:access_token, :jwt, or :id_token. The tokens must be issued by this OP, unexpired (within the clock leeway the other endpoints use), and not revoked. An id_token must have the calling client in its audience, and its scope is read from the stored consent, so withdrawn consent closes the exchange. An id_token issued to a pairwise client cannot be exchanged. requested_token_type, when sent, must be access_token. The issued token is always a JWT access token.
  • act chain: the OP builds act itself; the caller cannot supply one. The table below shows the three cases.
  • Resource/audience normalisation: URI-shaped values must be absolute with a scheme and host. RFC 8707 §2 lowercases the scheme and host, removes the default port (http:80 / https:443), and strips one trailing slash; fragments and userinfo are rejected, while query text is preserved. Opaque labels and URNs are compared verbatim. The audience and resource parameters are merged; when both are absent, the subject_token's audience is used. For example, https://api.example.com:443/v1/ becomes https://api.example.com/v1. Every requested audience must be in the client's Resources, or the exchange is invalid_target.
  • Scope: a requested scope must lie within the client's registered Scopes and within the subject_token's scope, or the exchange is invalid_scope (token_exchange.scope_inflation_blocked); a value is never dropped silently. When scope is omitted, the subject_token's scope narrowed to the registered Scopes is used. An empty result is invalid_scope too.
  • TTL ceiling: the minimum of GrantedTTL, the subject_token's remaining lifetime, and the OP's global access-token lifetime.
  • cnf rebinding: the issued token's cnf (DPoP jkt or mTLS x5t#S256) comes from the proof presented on this request.
  • Grant lineage: when the subject_token was issued under a stored grant, the exchanged tokens are issued under that same grant. Revoking the grant (RevokeSubject, Grant Management revoke, /end_session) or deleting the user revokes them, and a refresh token obtained through exchange stops rotating once its subject is deleted. A subject_token with no grant (for example one from client_credentials) yields tokens with no grant.
Caseact on the issued token
actor_token present{ "sub": <actor's sub> }, with the subject_token's earlier act nested inside
No actor_token, calling client differs from the subject_token's client{ "sub": <calling client_id> }, with any earlier act nested inside
No actor_token, calling client is the one the subject_token was issued to (self-exchange)No new entry; any earlier act is kept as is, and token_exchange.self_exchange is recorded
Scope ceilings — what's that?

The issued token's scope cannot exceed three different ceilings simultaneously: (a) what the request asked for, (b) what the subject_token already had, (c) what the calling client is allowed in its registration. A requested scope that exceeds (b) or (c) is invalid_scope, never silently trimmed. When the request omits scope, the subject_token's scope narrowed to (c) is used instead; an empty result is invalid_scope. This is what makes token-exchange safe — service-a cannot magically gain admin:write from a user token that only had profile.

TTL ceiling — what's that?

The issued token's lifetime is the minimum of three values: any GrantedTTL your policy returned, the remaining lifetime of the subject_token (an exchange cannot extend the user's session past where it would have ended anyway), and the OP's global access-token lifetime. A value over the ceiling is truncated and recorded as token_exchange.ttl_capped instead of being rejected, so a slightly-too-large policy value does not fail the exchange.

cnf rebinding — what's that?

A sender-bound token carries cnf, which names the key whose holder may use it. The exchanged token is for the party calling /token, so the OP stamps cnf from the proof on the exchange request instead of copying the subject_token's. A bound subject_token is accepted only when the request carries the matching proof, so a stolen bound token cannot be exchanged by someone without the key; an unbound (bearer) subject_token can be exchanged into a bound one. See Sender constraint for why this matters.

Self-exchange — what's that?

When the client that holds the subject_token is also the one requesting the exchange (subject_token.client_id == req.Client.ID) and sends no actor_token, no delegation happened: the client is narrowing its own token. act should record real delegation, so the OP adds no entry rather than one saying "service-a acted on behalf of itself".

A worked exchange request ​

Service A authenticates as itself and presents Alice's access token. With no actor_token, the OP records service-a (the calling client) as the actor:

sh
curl -s \
  -u service-a:<secret> \
  -H 'DPoP: <proof signed by service-a's key>' \
  -d 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
  -d "subject_token=$ALICES_ACCESS_TOKEN" \
  -d 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \
  -d 'audience=https://api.b.example.com' \
  -d 'scope=write:transfer' \
  https://op.example.com/oidc/token

The response also carries issued_token_type=urn:ietf:params:oauth:token-type:access_token, and the access token decodes to:

json
{
  "sub": "alice",
  "act": { "sub": "service-a" },
  "aud": "https://api.b.example.com",
  "scope": "write:transfer",
  "cnf": { "jkt": "<thumbprint of the DPoP key on this request>" },
  "iss": "https://op.example.com",
  "exp": ...
}

Service B verifies the DPoP proof against cnf.jkt, reads act.sub, and applies its own delegation rules ("transfers may be initiated by alice via service-a, but not by bob via service-a").

When the acting party is a different principal than the authenticated client, add actor_token and actor_token_type (same three type URNs). act.sub is then the actor token's sub. The DPoP header (which needs feature.DPoP enabled) is only needed for a DPoP-bound result; without it the token is a bearer token, unless the active profile requires sender constraint.

Refresh tokens — opt in only ​

By default, a token-exchange request does not issue a refresh token. RFC 8693 §2.2.1 makes the response parameter OPTIONAL because chained delegations should not silently extend their reach past the subject_token's lifetime.

To opt in, the policy returns IssueRefreshToken: op.PtrBool(true). The pointer shape prevents an accidental opt-in from a struct's zero value. The decision only states intent. The OP issues the refresh token when the provider serves refresh_token, the client is registered for that grant, and the response has a non-empty subject and scope and at most one audience. If a condition fails, the access-token response still succeeds without a refresh token, and custom_grant.refresh_dropped records the reason. Otherwise the OP creates the refresh token in its own refresh-token store, under the issued access token's grant and with the same DPoP / mTLS binding. token_exchange.refresh_issued is recorded only when the refresh token is actually issued.

Audit signal pairing ​

Every exchange attempt emits token_exchange.requested at handler entry, before parsing and validation. A successful exchange then emits token_exchange.granted once issuance has succeeded; if issuance fails afterwards, custom_grant.failed is recorded instead. A rejection emits requested plus one of the failure-class events, including for structurally unusable requests:

  • policy_denied / policy_error — your policy returned an error
  • scope_inflation_blocked — the request or the policy asked for scope beyond what the subject_token carries
  • audience_blocked — the request or the policy asked for an audience outside the allowed set
  • empty_scope_rejected — nothing was left of the scope
  • ttl_capped — the issued TTL was clipped (informational, not a denial)
  • act_chain_too_deep — the nested act chain exceeded five levels
  • actor_equals_subject — the actor resolves to the same subject as the subject_token
  • subject_token_invalid / subject_token_external / actor_token_external — a token failed verification or came from another issuer
  • subject_token_registry_error — a registry lookup faulted (transient outage); the wire response stays invalid_grant
  • self_exchange — informational, see the act table above

See the audit event catalog for the full list.

See it run ​

examples/33-token-exchange-delegation:

sh
(cd examples/33-token-exchange-delegation && GOWORK=off go run -tags example .)

A front end obtains Alice's access token with the authorization code flow. Service-a exchanges it for a token addressed to service-b (act={sub: service-a}, no actor_token), and service-b's verifier reads act.sub and rejects tokens that lack the chain. Files: op.go (OP wiring + TokenExchangePolicy), service_a.go (intermediary), service_b.go (resource server), probe.go (self-verify).