Skip to content

Use case — Token Exchange (RFC 8693)

For the conceptual background — impersonation vs delegation, what act records, 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 representing the user (or service) the new token will be issued on behalf ofsub of the issued token comes from here. actor_token is the token representing the caller making the exchange — the service that is acting on behalf of the subject. In a "service-a calls service-b on alice's behalf" chain, alice's access token is the subject_token, service-a's own access token is the actor_token. Self-exchange (subject == actor) is detected and the OP omits the act claim because no real delegation happened.

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 is the actor's subject; if the actor was itself the result of a previous exchange, its act is nested inside, producing a chain (alice → service-a → service-b). Resource servers walk act to check whole-chain authorisation: "transfers may be initiated by alice via service-a, but not by bob via service-a". The OP enforces a depth ceiling so a malicious chain cannot grow unboundedly.

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.what goes insubject_tokenAlice's access tokensub=alice · scope=profile writeactor_tokenservice-a's own tokensub=service-aOP /tokengrant_type=…:token-exchange1 · validate both input tokens2 · intersect the scopes3 · canonicalise the audience4 · rebuild act and cnf5 · run Policy.Allowany step may refuse — nothing is widenedwhat comes outThe issued tokenaud=service-bact.sub=service-aservice-bverifies act and cnf beforedeciding what alice-via-a may doThe exchange only ever narrows: the scopes are an intersection, the audience is a single service, and the actor is recorded rather thandropped. There is no shape of request that comes back with more authority than the subject token already carried.
Step 5 is where your policy lives. Everything above it is mechanical — the OP will not decide on its own whether service A is allowed to act for Alice against service B.

The cnf on the issued token binds to service-a's key, not to Alice's. The OP takes it from the verified DPoP or mTLS proof presented by the calling actor, never from subject_token.cnf.

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"
)

provider, err := op.New(
  op.WithIssuer("https://op.example.com"),
  op.WithStore(inmem.New()),
  op.WithKeyset(myKeyset),
  op.WithCookieKeys(myCookieKey),

  // Token exchange is an extension grant; select built-in grants explicitly.
  // RefreshToken is needed when the policy opts into refresh issuance.
  op.WithGrants(grant.ClientCredentials, grant.RefreshToken),
  op.RegisterTokenExchange(myPolicy),
  // ...
)

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

  1. Registers the RFC 8693 URN (urn:ietf:params:oauth:grant-type:token-exchange) at /token.
  2. Wires the in-tree handler that resolves subject_token / actor_token, normalises audiences, intersects scope, builds the act chain, rebinds cnf, and clamps TTL.
  3. Stores the embedder-supplied 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 RegisterTokenExchange twice fails with op.ErrTokenExchangeDuplicate.

Implementing TokenExchangePolicy

The policy interface is small but load-bearing. It receives a fully-resolved TokenExchangeRequest (subject and actor already verified, audience normalised, scope intersected) 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 non-zero, narrows the issued access-token lifetime below the provider-computed cap. A value greater than the cap is silently truncated with an audit warning. Negative is rejected.
IssueIDToken*bool override. Default = true when subject_token was an id_token, false otherwise. An explicit false suppresses the ID Token even when openid remains in the granted scope.
IssueRefreshToken*bool override. Default = nil = no refresh token. To opt in, set op.PtrBool(true) — the pointer shape exists so embedders cannot conflate "unset" with "false".
ExtraClaimsMerged into the id_token. Reserved-claim filter applies: iss / sub / aud / iat / exp / auth_time / nonce / acr / amr / azp / at_hash / c_hash / sid / act / cnf are dropped silently so the policy cannot rewrite the act chain, hijack sub, or forge cnf.

The subset rule is enforced again after the policy decision is applied. A policy bug that returns a broader scope or audience is rejected with invalid_scope / invalid_target and an audit event instead of minting a widened token.

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

These are not negotiable — the policy can narrow but never widen:

  • act chain construction: whenever the actor differs from the subject, the OP populates the issued token's act claim from the actor's verified credentials. The caller cannot fabricate an act.
  • 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. Default-fill from subject_token.audience when the request omits the parameter. For example, https://api.example.com:443/v1/ becomes https://api.example.com/v1.
  • Scope intersection: requested ∩ subject_token.scope ∩ client allow-list. Empty result = invalid_scope.
  • TTL ceiling: min(GrantedTTL, subject_token remaining, OP global cap).
  • cnf rebinding: the issued token's cnf (DPoP jkt or mTLS x5t#S256) is the calling actor's verified proof, not the subject's.
  • Self-exchange detection: when subject_token.client_id == req.Client.ID, no act entry is added (no real delegation happened).
Scope intersection — 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. The OP intersects all three; 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 (you cannot extend the user's session past where it would have ended anyway), and the OP's global access-token TTL cap. Going over the ceiling is silently truncated with an audit warning rather than rejected, so a slightly-too-large policy value doesn't fail closed.

cnf rebinding — what's that?

The subject_token may have been bound to alice's DPoP key. The exchanged token is going to service-a — it should be bound to service-a's key, not alice's, otherwise service-a can't actually use it. The OP "rebinds" cnf by replacing the subject_token's confirmation with the calling actor's verified DPoP / mTLS proof. See Sender constraint for why this matters.

Self-exchange — what's that?

When the same client both held the subject_token and is now requesting the exchange (subject_token.client_id == req.Client.ID), no real delegation happened — the client is just narrowing its own token. RFC 8693 says act should record actual delegation events, so the OP omits it in the self-exchange case to avoid producing a chain entry that says "service-a acted on behalf of itself".

A worked exchange request

sh
curl -s \
  -u service-a:<secret> \
  -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 "actor_token=$SERVICE_A_TOKEN" \
  -d 'actor_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 issued access token decodes to:

json
{
  "sub": "alice",
  "act": { "sub": "service-a", "client_id": "service-a" },
  "aud": ["https://api.b.example.com"],
  "scope": "write:transfer",
  "cnf": { "jkt": "<service-a's DPoP thumbprint>" },
  "iss": "https://op.example.com",
  "exp": ...
}

Service B verifies the DPoP proof against cnf.jkt, sees act.sub, and applies whatever delegation policy it has ("transfers may be initiated by alice via service-a, but not by bob via service-a").

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 (rather than a plain bool) prevents accidental opt-in from struct zero values. This records issuance intent; the shared custom-grant path honours it only when the provider serves refresh_token, the client is registered for that grant, the response has a non-empty subject and scope, and it names at most one audience. A failed gate leaves the access-token response successful, omits the refresh token, and emits custom_grant.refresh_dropped with the reason. When the gates pass, the OP mints and persists the refresh token through its own RefreshTokenStore, sharing the issued access token's grant identity and DPoP / mTLS binding. The exchange handler emits token_exchange.refresh_issued when it requests issuance; use token.issued to confirm that the chain root was actually persisted.

Audit signal pairing

Every exchange attempt emits token_exchange.requested at handler entry, before parsing and structural validation. Successful exchanges then emit token_exchange.granted (issued); rejections emit 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 — request asked for scope outside the intersection
  • audience_blocked — request asked for an audience outside the policy's allow-list
  • ttl_capped — issued TTL was clipped (warning, not denial)
  • act_chain_too_deep — nested act chain exceeded the depth limit
  • subject_token_invalid — subject_token verification failed
  • subject_token_registry_error — registry lookup faulted (transient outage); wire stays invalid_grant

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 .)

Frontend → service-a → service-b chain. Service-a exchanges Alice's token for a delegated token (act={sub: service-a}); service-b's RS-side verifier walks act.sub and rejects tokens missing the chain. Files: op.go (OP wiring + TokenExchangePolicy), service_a.go (intermediary), service_b.go (resource server), probe.go (self-verify).