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.
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
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:
- Registers the RFC 8693 URN (
urn:ietf:params:oauth:grant-type:token-exchange) at/token. - Wires the built-in handler that resolves
subject_token/actor_token, normalises audiences, bounds scope, builds theactchain, rebindscnf, and clamps TTL. - Stores your
TokenExchangePolicyfor 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.
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
| Field | Behaviour |
|---|---|
GrantedScope | When non-empty, replaces the provider-computed set. MUST be a subset of req.RequestedScope; broader values yield invalid_scope. |
GrantedAudience | When non-empty, replaces the provider-computed set. MUST be a subset of req.RequestedAudience; broader values yield invalid_target. |
GrantedTTL | When 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". |
ExtraClaims | Merged 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
| Return | Wire 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_typeandactor_token_typemust be one ofurn: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 beaccess_token. The issued token is always a JWT access token. actchain: the OP buildsactitself; 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. Theaudienceandresourceparameters are merged; when both are absent, thesubject_token's audience is used. For example,https://api.example.com:443/v1/becomeshttps://api.example.com/v1. Every requested audience must be in the client'sResources, or the exchange isinvalid_target. - Scope: a requested scope must lie within the client's registered
Scopesand within thesubject_token's scope, or the exchange isinvalid_scope(token_exchange.scope_inflation_blocked); a value is never dropped silently. Whenscopeis omitted, thesubject_token's scope narrowed to the registeredScopesis used. An empty result isinvalid_scopetoo. - TTL ceiling: the minimum of
GrantedTTL, thesubject_token's remaining lifetime, and the OP's global access-token lifetime. cnfrebinding: the issued token'scnf(DPoPjktor mTLSx5t#S256) comes from the proof presented on this request.- Grant lineage: when the
subject_tokenwas 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. Asubject_tokenwith no grant (for example one fromclient_credentials) yields tokens with no grant.
| Case | act 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:
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/tokenThe response also carries issued_token_type=urn:ietf:params:oauth:token-type:access_token, and the access token decodes to:
{
"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 errorscope_inflation_blocked— the request or the policy asked for scope beyond what thesubject_tokencarriesaudience_blocked— the request or the policy asked for an audience outside the allowed setempty_scope_rejected— nothing was left of the scopettl_capped— the issued TTL was clipped (informational, not a denial)act_chain_too_deep— the nestedactchain exceeded five levelsactor_equals_subject— the actor resolves to the same subject as thesubject_tokensubject_token_invalid/subject_token_external/actor_token_external— a token failed verification or came from another issuersubject_token_registry_error— a registry lookup faulted (transient outage); the wire response staysinvalid_grantself_exchange— informational, see theacttable above
See the audit event catalog for the full list.
See it run
examples/33-token-exchange-delegation:
(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).
Read next
- Token Exchange primer — impersonation vs delegation, the conceptual background.
- Custom Grant wiring — the URN-routing seam token exchange uses; handlers for other URNs follow the same shape.
- Sender constraint — why
cnfrebinding to the actor is the right default.