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.
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.
| Parameter | Required | Description |
|---|---|---|
grant_type | Yes | Must be urn:ietf:params:oauth:grant-type:token-exchange. |
subject_token | Yes | The token representing the identity to be acted upon. |
subject_token_type | Yes | urn: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_token | Yes | The 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_type | Yes | urn:ietf:params:oauth:token-type:access_token for SlashID access tokens; urn:ietf:params:oauth:token-type:jwt for SlashID-issued JWTs. |
requested_token_type | Yes | Either 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_in | No | Positive 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. |
scope | No | Space-delimited scope list. Access token output only. With JWT output the parameter is rejected with invalid_scope. |
audience | No | Free-form audience values (multi-valued). Access token output only. With JWT output the parameter is rejected with invalid_target. |
resource | No | Absolute 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:
| URI | Token shape it accepts |
|---|---|
urn:ietf:params:oauth:token-type:access_token | SlashID access token (prefix slashid-access:) |
urn:ietf:params:oauth:token-type:jwt | SlashID-issued JWT (three-segment JWS — user JWT or TokenContainer) |
| Shape | Resolves 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
adminorimpersonatorRBAC group in the subject's organization. The check is evaluated through the sameinherits_rbac_poolsmachinery 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-exchangegrant type enabled (via itsgrant_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 omitted | requested_token_type = …:jwt | requested_token_type = …:access_token |
|---|---|---|---|
| No | invalid_request | SlashID JWT | invalid_request |
| Yes | invalid_request | SlashID JWT | Access 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.
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
Authorizationheader) cannot use cross-region opaque actors — the response isinvalid_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/tokensendpoint 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
actlevels. Beyond that the request is rejected withinvalid_grant. - An
actor_tokenthat itself already carries anactclaim is rejected withinvalid_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:
| Code | When |
|---|---|
invalid_request | Missing 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_client | Client credentials were sent but the client does not exist or the secret is wrong. |
unauthorized_client | The authenticated caller client does not have the token-exchange grant type enabled in its grant_types. |
invalid_grant | Unverifiable 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_scope | Requested scope exceeds the ceiling; scope supplied with JWT output. |
invalid_target | Requested 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_type | The 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/abcandspiffe://td/workload/abcare 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 alongsideauthorization_code,refresh_tokenandclient_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.