Skip to main content

Guide: RFC 8693 OAuth 2.0 Token Exchange

Introduction

RFC 8693 defines an OAuth 2.0 extension that lets a client trade an existing token for another one — typically to obtain a token usable by a different party, against a different audience, or with a different scope. SlashID implements the spec on the existing POST /oauth2/tokens endpoint by registering the additional grant type urn:ietf:params:oauth:grant-type:token-exchange. No new route is introduced; clients address it through the standard token endpoint.

The use case is delegation: an actor (a person, or an OAuth2 client) wants to act on behalf of a subject (always a person) and needs a token that downstream services can verify. The exchanged token carries the subject's identity but adds an act claim that records who the actor is. When delegation chains (A acts for B acts for C), SlashID nests the act claims so the full history is preserved in a single token.

Token exchange is synchronous, machine-friendly, and bound by RBAC: the actor must hold sufficient authority in the subject's organization. SlashID's implementation never issues a refresh token for an exchanged token — the revocation story is short TTLs.

info

The legacy /persons/{person_id}/impersonate/v2 flow remains available and is browser-oriented. Token exchange is the right choice when both the actor and the subject already hold tokens and a machine needs to mint the delegation token without a browser redirect.

Grant type and required parameters

Submit a POST /oauth2/tokens request encoded as application/x-www-form-urlencoded. Client authentication (HTTP Basic or form parameters) is optional — but if credentials are sent, they must authenticate a valid client whose registration has the token-exchange grant type enabled, otherwise invalid_client or unauthorized_client is returned.

ParameterRequiredDescription
grant_typeYesMust be urn:ietf:params:oauth:grant-type:token-exchange.
subject_tokenYesThe token representing the identity to be acted upon.
subject_token_typeYesurn:ietf:params:oauth:token-type:access_token for SlashID access tokens; urn:ietf:params:oauth:token-type:jwt for SlashID-issued JWTs. See Accepted input token types.
actor_tokenYesThe token of the acting party. SlashID implements delegation, not impersonation, so the actor token is mandatory and is never substituted by client credentials.
actor_token_typeYesurn:ietf:params:oauth:token-type:access_token for SlashID access tokens; urn:ietf:params:oauth:token-type:jwt for SlashID-issued JWTs.
requested_token_typeYesEither urn:ietf:params:oauth:token-type:jwt (SlashID JWT) or urn:ietf:params:oauth:token-type:access_token (access token). See Output selection. The server does not pick a default; omitting the parameter yields invalid_request.
requested_expires_inNoPositive integer (seconds), at most 31536000 (one year). Can only shorten the issued token's lifetime; values ≤ 0, non-numeric, or above the cap yield invalid_request.
scopeNoSpace-delimited scope list. Access token output only. With JWT output the parameter is rejected with invalid_scope.
audienceNoFree-form audience values (multi-valued). Access token output only. With JWT output the parameter is rejected with invalid_target.
resourceNoAbsolute URIs without fragment (RFC 8707). Treated identically to audience after the two sets are merged. Access token output only.

The grant rejects all other subject_token_type and actor_token_type URIs (such as …:id_token, …:refresh_token, …:saml1, …:saml2) with invalid_request.

Accepted input token types

subject_token and actor_token are dispatched by the token-type URI. The URI names the token shape, not its provenance:

URIToken shape it accepts
urn:ietf:params:oauth:token-type:access_tokenSlashID access token (prefix slashid-access:)
urn:ietf:params:oauth:token-type:jwtSlashID-issued JWT (three-segment JWS — user JWT or TokenContainer)
ShapeResolves to
SlashID user JWT (…:jwt)Person actor
Access token from the authorization-code grant (…:access_token)Person actor
Access token from the client-credentials grant (…:access_token)OAuth2 client actor

See Multi-region routing below for how the grant routes by the subject_token's home region and what works (or doesn't) for cross-region actors.

Who can act

The token exchange grant is gated separately for each actor kind:

  • Person actor. The actor must hold the admin or impersonator RBAC group in the subject's organization. The check is evaluated through the same inherits_rbac_pools machinery used elsewhere, so suborg/group inheritance works as expected. Authless person actor tokens are allowed — the gate is group membership, not factor strength.
  • OAuth2 client actor. The OAuth2 client registration referenced by the actor token must have the urn:ietf:params:oauth:grant-type:token-exchange grant type enabled (via its grant_types). The client and the subject must belong to the same organization.

Subject and actor must always belong to the same organization. The issued token's organization is always the subject token's organization — there is no re-targeting.

Subjects must always resolve to a person. A client-bound subject token is rejected with invalid_grant.

Output selection

The issued token is either a SlashID JWT or an access token. The choice is driven by requested_token_type, which is required — the server does not pick a default; an omitted value is rejected with invalid_request. The only other constraint is that an access token output requires a caller client.

Caller authenticated with client credentials?requested_token_type omittedrequested_token_type = …:jwtrequested_token_type = …:access_token
Noinvalid_requestSlashID JWTinvalid_request
Yesinvalid_requestSlashID JWTAccess token

An access token output requires a caller client because access tokens are client-bound: introspection reports the owning client_id, and revocation is scoped to that client. Anyone can ask for a JWT.

The scope, audience and resource parameters apply only to access token output. With JWT output, scope yields invalid_scope, while audience and resource yield invalid_target.

For access token output the scope ceiling is the caller client's registered scopes; when the subject is itself an access token that already had scopes granted, the ceiling narrows further to the scopes shared between the two. Omitting scope grants the full ceiling. A requested scope outside the ceiling fails the whole request with invalid_scope — no partial grant.

For audience/resource, every requested value must be in the caller client's audience allow-list. Any value outside the list fails the whole request with invalid_target. Omitting both grants the client's full audience list.

Access token subjects cannot be exchanged for JWTs

SlashID JWTs are unscoped while access tokens carry granted scopes. The grant enforces a one-way rule: once a token has scopes, every derivative must also have scopes. Concretely, when subject_token is an access token the only permitted requested_token_type is urn:ietf:params:oauth:token-type:access_token — a JWT-typed request fails with invalid_request.

Without this rule, a scope-constrained access token could be laundered through a JWT hop (exchange A → JWT B, exchange B → access token C, where C would inherit the caller client's full registered scopes). Blocking the first hop makes the chain structurally impossible.

JWT → JWT, JWT → access token, and access token → access token chaining are all unaffected.

Multi-region routing

SlashID runs the token-exchange endpoint in every region; SlashID identities and OAuth2 clients exist globally (organizations, persons and clients are replicated cross-region), but the fosite session storage backing access tokens is per-region. A token issued in region A only resolves to an active session in region A.

The grant routes by the subject token's home region:

  • JWT subject_token: the home region is the EPID region encoded in the PersonID claim.
  • Opaque (urn:…:access_token) subject_token: the home region is the HMAC-encoded region in the token body.

If the request hits a region other than the subject's home, the gate transparently re-routes the entire HTTP request to the right region. You can target any region (e.g. api.slashid.com); the routing is internal.

Cross-region actor tokens:

  • Same region as the subject (the dominant case): works regardless of actor token shape.
  • Opaque OAuth2 client actor in a different region from the subject: works with client-authenticated requests only. The subject's region introspects the actor token by calling the actor's region behind the scenes, replaying your basic-auth client credentials (clients are replicated, so the same credentials authenticate the cross-region call). Clientless callers ("clientless", no Authorization header) cannot use cross-region opaque actors — the response is invalid_grant.
  • Opaque person actor in a different region from the subject: not supported in this release. The actor's RBAC group membership is checked in the subject's region against the actor's home region, and there is no cross-region groups lookup yet. The response is invalid_grant.
  • JWT actor in a different region from the subject: not supported in this release. SlashID token signing keysets are region-local and the internal verifier only loads the local region's keyset. A JWT signed elsewhere fails signature verification with invalid_grant.

Workarounds when the actor lives in a different region than the subject:

  • Re-mint the actor token in the subject's region. For machine actors, hit the subject region's /oauth2/tokens endpoint directly with the same client credentials (clients are replicated). For human actors, complete the authorize-code flow in the subject's region so the resulting JWT is signed by that region's keyset.
  • For opaque OAuth2 client actors with a client-authenticated caller, no workaround is needed — the cross-region introspect path handles it transparently.

TTL

The issued token expires at the soonest of: the subject token's expiry, the actor token's expiry, the organization's configured token TTL, and (if set) the caller's requested_expires_in. requested_expires_in can only shorten that ceiling — never extend it.

Over-asking requested_expires_in is capped silently rather than rejected — the expires_in field in the response is the authoritative feedback channel. (Scope over-asking errors because scope is deterministic; time is not, given clock skew and request latency.)

No refresh token is ever issued for an exchanged token. For JWT output the revocation story is the short TTL. Extending a SlashID user token requires re-solving a verified challenge as the subject, which the actor cannot do.

GET /token (SwitchOrg) remains reachable with an exchanged JWT and preserves the act chain without extending the TTL.

Nested act claim and chaining

After a successful exchange, the issued JWT keeps the subject's identity claims (org, EPID, person type, groups, GDPR consents) as well as the subject's authenticated_methods / authentications — the issued token records how the subject originally authenticated, not the act of delegation. The delegation itself is conveyed by the act claim only:

  • act: an RFC 8693 actor claim.

When the subject token carries no authentication chain (e.g. a SVID subject), the issued token's authenticated_methods / authentications are empty — act is then the sole source of truth.

The actor claim records the immediate actor at the top level. If the subject token already carried an act chain, that chain is preserved nested under the new claim — the most recent actor sits outermost. After two hops the JWT looks like this:

{
"sub": "alice",
"act": {
"sub": "bob",
"actor_type": "person",
"act": {
"sub": "carol",
"actor_type": "person"
}
}
}

In this example, alice is the subject, bob is the most recent actor, and carol was the actor in the previous hop. The subject (sub) never changes across exchanges. The actor_type field (person or oauth2_client) explicitly identifies the actor kind so consumers do not need to infer it from the ID format.

Chain rules:

  • Maximum chain depth in the issued token is 5 nested act levels. Beyond that the request is rejected with invalid_grant.
  • An actor_token that itself already carries an act claim is rejected with invalid_grant. Flattening two delegation chains into one would fabricate an ordering that never happened.

For access token output, the same act structure is embedded in the stored token session so introspection exposes it.

Error codes

POST /oauth2/tokens returns the standard RFC 6749/8693 error codes:

CodeWhen
invalid_requestMissing or malformed parameter; requested_expires_in ≤ 0; access token output requested without client authentication; unsupported subject_token_type/actor_token_type URI; missing requested_token_type; access-token subject_token requested as …:jwt.
invalid_clientClient credentials were sent but the client does not exist or the secret is wrong.
unauthorized_clientThe authenticated caller client does not have the token-exchange grant type enabled in its grant_types.
invalid_grantUnverifiable or expired subject/actor token; cross-organization subject/actor; opaque subject/actor token issued to a different client than the caller; person actor without the required RBAC group; OAuth2 client actor without the grant enabled; chain depth exceeded; actor_token already carries an act claim; cross-region access token actor.
invalid_scopeRequested scope exceeds the ceiling; scope supplied with JWT output.
invalid_targetRequested audience/resource outside the caller client's allow-list; audience/resource supplied with JWT output; resource value is not an absolute URI without fragment.
unsupported_grant_typeThe endpoint does not recognise the grant_type value.

Examples

A) Clientless exchange, JWT output

Mint a SlashID JWT in which bob acts on behalf of alice. Both inputs are SlashID user JWTs, declared as such via …:jwt. No client credentials are sent; requested_token_type is mandatory and we explicitly request a JWT.

curl --location 'https://api.slashid.com/oauth2/tokens' \
--header 'SlashID-OrgID: <ORG ID>' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode 'subject_token=<subject SlashID JWT>' \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:jwt' \
--data-urlencode 'actor_token=<actor SlashID JWT>' \
--data-urlencode 'actor_token_type=urn:ietf:params:oauth:token-type:jwt' \
--data-urlencode 'requested_token_type=urn:ietf:params:oauth:token-type:jwt'

Response:

{
"access_token": "<signed SlashID JWT>",
"issued_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_type": "Bearer",
"expires_in": 3540
}

Decoded, the JWT carries the subject identity and a top-level act claim:

{
"sub": "alice",
"oid": "<org id>",
"act": {
"sub": "bob",
"actor_type": "person",
"authentications": [ { "method": "..." } ]
},
"authentications": [ { "method": "email_link" } ],
"authenticated_methods": [ "email_link" ],
"exp": 1718200000,
"iat": 1718196460
}

authentications and authenticated_methods mirror what the subject token already carried — email_link here is just an example. The act claim is what marks this token as delegated.

B) Authenticated client, access token output with scope and audience, plus introspection

Authenticate the caller with HTTP Basic to obtain an access token scoped to calendar and bound to https://api.example.com.

curl --location 'https://api.slashid.com/oauth2/tokens' \
-u '<client_id>:<client_secret>' \
--header 'SlashID-OrgID: <ORG ID>' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode 'subject_token=<subject SlashID JWT>' \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:jwt' \
--data-urlencode 'actor_token=<actor SlashID JWT>' \
--data-urlencode 'actor_token_type=urn:ietf:params:oauth:token-type:jwt' \
--data-urlencode 'requested_token_type=urn:ietf:params:oauth:token-type:access_token' \
--data-urlencode 'scope=calendar' \
--data-urlencode 'audience=https://api.example.com'

Response:

{
"access_token": "slashid-access:abcDEF1234…",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 3540,
"scope": "calendar"
}

Introspect the issued access token (using the same client credentials):

curl --location 'https://api.slashid.com/oauth2/tokens/introspect' \
-u '<client_id>:<client_secret>' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'token=slashid-access:abcDEF1234…'

Response (excerpt — act and aud are surfaced):

{
"active": true,
"client_id": "<client_id>",
"sub": "alice",
"scope": "calendar",
"aud": ["https://api.example.com"],
"act": {
"sub": "bob",
"actor_type": "person"
},
"exp": 1718200000,
"iat": 1718196460,
"token_type": "Bearer"
}

SPIFFE JWT-SVID actor tokens

A SPIFFE workload (identified by a SPIFFE URI like spiffe://example.org/workload/foo) can act on behalf of a SlashID person by presenting a JWT-SVID as the actor_token.

Prerequisites

Configure the organization's SVID trust anchors via the org-config API:

curl --location --request PATCH 'https://api.slashid.com/organizations/<ORG ID>/config' \
--header 'SlashID-API-Key: <API KEY>' \
--header 'Content-Type: application/json' \
--data '{
"svid_jwks_urls": {
"spiffe://prod.example.org": "https://prod.example.org/jwks.json",
"spiffe://staging.example.org": "https://staging.example.org/jwks.json"
},
"allowed_spiffe_ids": [
"spiffe://prod.example.org",
"spiffe://staging.example.org/workload/cron-runner"
]
}'
  • svid_jwks_urls — map of SPIFFE trust domain to JWKS endpoint. Each trust domain points at the JWKS that vouches for workloads in that domain.
  • allowed_spiffe_ids — each entry is either a trust domain (matches any workload in that domain) or a full SPIFFE ID (matches exactly one workload). Match is exact string equality, so the path component is case-sensitive: spiffe://td/Workload/abc and spiffe://td/workload/abc are different entries. Trust domains are normalised to lowercase by the SPIFFE spec at validation time, so the host part is not case-sensitive.

Exchanging

Send the SVID as actor_token with actor_token_type = urn:ietf:params:oauth:token-type:jwt. Either a client-authenticated request (Basic auth) or a clientless caller works:

curl --location 'https://api.slashid.com/oauth2/tokens' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode 'subject_token=<SlashID person token>' \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \
--data-urlencode 'actor_token=<JWT-SVID>' \
--data-urlencode 'actor_token_type=urn:ietf:params:oauth:token-type:jwt' \
--data-urlencode 'requested_token_type=urn:ietf:params:oauth:token-type:access_token'

The issued token's act claim records actor_type: "spiffe" and the SPIFFE ID as act.sub.

Verification

We verify the JWT-SVID against the JWKS at the URL configured for the token's trust domain. JWKS responses are refreshed every 5 minutes; a revoked key propagates within that window in the worst case. If a refresh fetch fails the previous JWKS keeps serving until the next successful refresh.

If the JWT-SVID sets aud, it must include either the bare API origin (https://api.slashid.com) or the token-exchange endpoint URL (https://api.slashid.com/oauth2/tokens). Both are accepted because SPIFFE workloads usually request a coarse service identifier from SPIRE (the bare origin), while RFC 7523 §3 calls out the token endpoint URL as the canonical AS-identifying audience. Match is exact-equal — substrings or prefixes don't count. If aud is absent, we accept it — SPIFFE makes that field optional.

Disabling

To turn SVID off for an organization, clear both halves in the same PATCH:

{ "svid_jwks_urls": {}, "allowed_spiffe_ids": [] }

Clearing only one half is rejected — a half-configured org would always fail at the actor authorizer at exchange time, so the config-time check surfaces the inconsistency immediately.

Disabling signature verification per trust domain (testing only)

If a trust domain is configured with a JWKS URL of null in svid_jwks_urls, the server skips JWKS fetch and signature verification for JWT-SVIDs from that trust domain. Every other check still applies (exp required, iss trust-domain match when present, aud match when present, allowlist gate). This is available in production to support customer-side end-to-end testing where standing up a real HTTPS JWKS endpoint isn't practical; the server emits a structured warning on every use so accidental enablement is greppable.

Example PATCH body:

{
"svid_jwks_urls": {
"spiffe://test.local": null,
"spiffe://prod.example.org": "https://jwks.example.org/.well-known/jwks.json"
},
"allowed_spiffe_ids": [
"spiffe://test.local",
"spiffe://prod.example.org/workload/cron"
]
}

Setting null is a customer decision and trusts any JWT-SVID claiming to be from that trust domain. Scope it to a dedicated test trust domain (e.g. spiffe://test.local) and never mix it with a real production trust domain entry.

See also

  • The auto-generated reference for the underlying endpoint: POST /oauth2/tokens. The token exchange grant is one of the variants of that endpoint and is documented there alongside authorization_code, refresh_token and client_credentials.
  • Using SlashID as an OIDC Provider — the authorization code flow that mints the subject and actor JWTs.
  • M2M and API authentication — minting and managing OAuth 2.0 clients, including the ones used as actors via client credentials.