Skip to content

RFC 8693 — OAuth 2.0 Token Exchange ​

Spec: datatracker.ietf.org/doc/html/rfc8693Status: Partial

RFC 8693 defines a Security Token Service (STS) endpoint that exchanges one token for another. AuthHero implements two slices of it on /oauth/token, selected by subject_token_type:

subject_token_typeFlowSubject token
urn:ietf:params:oauth:token-type:access_tokenOrganization switching (this page)An access token AuthHero issued itself
A custom URI you register, e.g. urn:acme:session-tokenCustom Token ExchangeA token your own backend signed, verified against your JWKS or by an action

Use Custom Token Exchange when a trusted backend that runs its own login needs AuthHero tokens for one of its users. It is compatible with Auth0's feature of the same name. It returns tokens only and does not create an AuthHero browser session. See the Custom Token Exchange guide for profiles, user mapping and errors.

The rest of this page covers organization switching. A confidential client can exchange a self-issued access token for a new access token scoped to a different organization, optionally with a narrower scope set. The new token carries an act claim identifying the exchanging client.

The primary use case is: a control-plane access token (representing a user with broad permissions across a tenant) is presented to a backend service, which exchanges it for an organization-scoped token before calling tenant resources.

How a client uses it ​

The exchanging client posts to /oauth/token:

text
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<self-issued access token>
subject_token_type=urn:ietf:params:oauth:token-type:access_token
organization=<target organization id>
client_id=<exchanging client>
client_secret=<...>           # or client_assertion (RFC 7523)
audience=<resource server>    # optional — defaults to the subject token's aud
scope=<requested scopes>      # optional — defaults to the subject token's scope

The response is a standard token response:

json
{
  "access_token": "<JWT>",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "read:things",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token"
}

The new access token has:

  • sub — the original user (preserved from the subject token).
  • aud — the requested (or inherited) audience.
  • org_id — the target organization.
  • scope — the requested scopes (must be a subset of the subject token's).
  • act — { "sub": "<exchanging client_id>", "client_id": "<exchanging client_id>" }, per RFC 8693 §4.1.

No refresh token is issued. The flow is meant to be re-run on demand, not refreshed.

Validation pipeline ​

Each request runs through, in order:

  1. Client authentication. The exchanging client must authenticate via client_secret (RFC 6749 client_secret_post/client_secret_basic) or client_assertion (RFC 7523). Public clients are rejected.
  2. organization_usage gate. The exchanging client's organization_usage must not be deny. New clients (including those registered via Dynamic Client Registration and CIMD) default to deny, so token exchange is opt-in per client.
  3. grant_types allowlist. The exchanging client must list urn:ietf:params:oauth:grant-type:token-exchange in its grant_types (standard RFC 6749 §5.2 enforcement).
  4. Subject token signature and issuer. The subject_token is verified against the tenant's JWKS. Tokens signed by a different issuer are rejected with invalid_grant.
  5. Subject token freshness. Expired tokens are rejected with invalid_grant.
  6. No chained exchange. If the subject_token already carries an act claim, the request is rejected — exchanged tokens are not themselves exchangeable.
  7. User resolution. The sub claim must resolve to a known user. Linked users follow the same linked_to chain the other grants use.
  8. Organization exists. The organization parameter must identify a real organization in the tenant.
  9. Audience is a resource server. The resolved audience must be a registered resource server. This stops the exchange from minting tokens for an audience that isn't authoritatively configured.
  10. Authorization. The user must be a member of the target organization, OR hold, at global scope on the Management API (urn:authhero:management), either access:all_organizations or admin:organizations with the tenant's inherit_global_permissions_in_organizations flag enabled. Every organization gate (login, silent auth, refresh token) applies the same rule.
  11. Downscope. Any requested scope value must be present in the subject token's scope. Exceeding the subject token's scope returns invalid_scope.

What's not implemented ​

The accepted surface is narrow on purpose. The following parts of RFC 8693 are not implemented:

  • Other standard subject_token_type values. Of the IETF-registered types, only urn:ietf:params:oauth:token-type:access_token is accepted. id_token, refresh_token, jwt, saml1 and saml2 are rejected. Tokens from another issuer are accepted only through Custom Token Exchange, under a custom subject_token_type registered in a token exchange profile. As on Auth0, profiles cannot claim the reserved urn:ietf:params:oauth: namespace.
  • actor_token / actor_token_type. Delegation chains with an explicit actor token are not supported. The acting party is always the authenticated exchanging client, recorded in act automatically.
  • requested_token_type. Organization switching always returns an access token. Asking for a different requested_token_type (e.g. id_token) is ignored.
  • Resource indicators (RFC 8707). The resource parameter is not honored; use audience to specify the target.
  • Chained exchange. Tokens minted via token-exchange are not themselves exchangeable. RFC 8693 §4.1 allows nested actors; AuthHero deliberately rejects them.

Configuration ​

Token exchange requires no special client type, just the standard fields:

jsonc
{
  "client_id": "exchange-service",
  "client_secret": "...",
  "grant_types": [
    "client_credentials",
    "urn:ietf:params:oauth:grant-type:token-exchange",
  ],
  "organization_usage": "allow", // or "require" — must NOT be "deny"
}

To bypass membership, assign access:all_organizations to the user (or via a role) at tenant level on the Management API. The older admin:organizations bypass also works but needs the tenant flag inherit_global_permissions_in_organizations. See Access to every organization.

Use with CIMD-registered clients ​

CIMD clients are public clients (no client_secret) and their grant_types are filtered to authorization_code and refresh_token. They cannot be the exchanging client for a token-exchange call. They can, however, be the subject — the typical pattern is:

  1. A CIMD-registered client (e.g. an MCP client) obtains a user-context access token via authorization code.
  2. The user presents that token to a backend service.
  3. The backend service — registered as a confidential client with token-exchange in its grant_types — calls /oauth/token to exchange the subject token for an org-scoped one.

This split keeps the exchange capability gated behind a server-side credential while still allowing public CIMD clients to drive the user-facing flow.

Audit and logging ​

Successful exchanges emit SUCCESS_EXCHANGE_SUBJECT_TOKEN_FOR_ACCESS_TOKEN (sestft); failed exchanges emit FAILED_EXCHANGE_SUBJECT_TOKEN_FOR_ACCESS_TOKEN (festft) with a description of which validation step rejected the request. The exchanging client_id is recorded in the new token's act claim, so resource servers and audit consumers can distinguish exchanged tokens from directly issued ones.

Dual-licensed: AGPL-3.0-only or commercial license.