Plugin - Token exchange (OBO delegation)
This plugin lets Gate stand in front of a protected service and exchange the
caller's token for a delegated token that acts on behalf of the caller, with
Gate (or a configured workload identity) recorded as the actor in the
RFC 8693 act claim
chain. The original token is stripped from the proxied request and replaced
with the delegated one.
Concretely: an MCP server, an internal API or any other consumer can rely on Gate as the request-time delegate. Upstreams receive a delegated token that proves "X acted on behalf of Y" — not the raw user token.
Modes
The plugin supports three independent actor sources, selected with
actor_mode. The subject is always read from the inbound request (header or
cookie); only the actor changes between modes.
actor_mode | What Gate sends as actor_token |
|---|---|
oauth_client | A client_credentials access token Gate mints for itself against the SlashID /oauth2/tokens endpoint, using a SlashID OAuth2 client registered with the urn:ietf:params:oauth:grant-type:token-exchange grant. Cached locally and refreshed near expiry. |
gate_svid | A SPIFFE JWT-SVID Gate sources from one of file, env or workload_api (the SPIRE Workload API socket). The SlashID org must list the trust domain in svid_jwks_urls and the SPIFFE ID in allowed_spiffe_ids. |
request_actor | An actor token carried by the inbound request itself (configurable header or cookie). Accepts any token shape the SlashID /oauth2/tokens endpoint accepts: a SlashID-issued person JWT, an opaque access token, or a SPIFFE JWT-SVID. |
A single Gate config can declare any number of token-exchange plugins side
by side — e.g. one route per actor mode — by giving each a distinct id.
Caller-client authentication
When the inbound subject is an opaque SlashID access token, RFC 6749
binds it to the OAuth2 client it was issued to. The SlashID
/oauth2/tokens endpoint requires the call to be authenticated as the same
client; configure oauth_client_id / oauth_client_secret on the plugin and
Gate will attach HTTP Basic auth to the exchange call.
When the subject is a SlashID JWT (e.g. minted with
POST /persons/{id}/mint-token), the binding doesn't apply and the call can
be clientless — omit oauth_client_id / oauth_client_secret.
actor_mode: oauth_client reuses the same oauth_client_id /
oauth_client_secret for both the actor identity (used to mint the
client_credentials access token) and the caller-client Basic auth.
Configuring Gate
A minimal config for each actor mode is below. Pick the format you use and the mode that fits your setup; see the Field reference at the bottom for everything you can tune.
- Environment variables
- HCL
- JSON
- TOML
- YAML
# actor_mode = oauth_client
GATE_PLUGINS_<N>_TYPE=token-exchange
GATE_PLUGINS_<N>_PARAMETERS_SLASHID_BASE_URL=https://api.slashid.com
GATE_PLUGINS_<N>_PARAMETERS_SUBJECT_HEADER_WITH_TOKEN=Authorization
GATE_PLUGINS_<N>_PARAMETERS_ACTOR_MODE=oauth_client
GATE_PLUGINS_<N>_PARAMETERS_OAUTH_CLIENT_ID=<oauth2 client id>
GATE_PLUGINS_<N>_PARAMETERS_OAUTH_CLIENT_SECRET=<oauth2 client secret>
GATE_PLUGINS_<N>_PARAMETERS_REQUESTED_TOKEN_TYPE=urn:ietf:params:oauth:token-type:jwt
# actor_mode = gate_svid via the SPIRE Workload API
GATE_PLUGINS_<N>_PARAMETERS_ACTOR_MODE=gate_svid
GATE_PLUGINS_<N>_PARAMETERS_ACTOR_SVID_SOURCE=workload_api
GATE_PLUGINS_<N>_PARAMETERS_ACTOR_WORKLOAD_API_SOCKET=unix:///run/spire/sockets/agent.sock
GATE_PLUGINS_<N>_PARAMETERS_ACTOR_WORKLOAD_API_AUDIENCE=https://api.slashid.com/oauth2/tokens
# actor_mode = request_actor
GATE_PLUGINS_<N>_PARAMETERS_ACTOR_MODE=request_actor
GATE_PLUGINS_<N>_PARAMETERS_ACTOR_HEADER_WITH_TOKEN=X-Actor-Token
In Environment variable configuration, <N> defines plugin execution order.
gate = {
plugins = [
// ...
{
type = "token-exchange"
parameters = {
slashid_base_url = "https://api.slashid.com"
subject_header_with_token = "Authorization"
subject_strip_after_exchange = true
// actor_mode = oauth_client | gate_svid | request_actor
actor_mode = "oauth_client"
oauth_client_id = "<oauth2 client id>"
oauth_client_secret = "<oauth2 client secret>"
requested_token_type = "urn:ietf:params:oauth:token-type:jwt"
output_header = "Authorization"
output_prefix = "Bearer "
upstream_request_timeout = "5s"
}
}
// ...
]
}
{
"gate": {
"plugins": [
{
"type": "token-exchange",
"parameters": {
"slashid_base_url": "https://api.slashid.com",
"subject_header_with_token": "Authorization",
"subject_strip_after_exchange": true,
"actor_mode": "oauth_client",
"oauth_client_id": "<oauth2 client id>",
"oauth_client_secret": "<oauth2 client secret>",
"requested_token_type": "urn:ietf:params:oauth:token-type:jwt",
"output_header": "Authorization",
"output_prefix": "Bearer ",
"upstream_request_timeout": "5s"
}
}
]
}
}
[[gate.plugins]]
type = "token-exchange"
parameters.slashid_base_url = "https://api.slashid.com"
parameters.subject_header_with_token = "Authorization"
parameters.subject_strip_after_exchange = true
parameters.actor_mode = "oauth_client"
parameters.oauth_client_id = "<oauth2 client id>"
parameters.oauth_client_secret = "<oauth2 client secret>"
parameters.requested_token_type = "urn:ietf:params:oauth:token-type:jwt"
parameters.output_header = "Authorization"
parameters.output_prefix = "Bearer "
parameters.upstream_request_timeout = "5s"
gate:
plugins:
# actor_mode = oauth_client
- type: token-exchange
parameters:
slashid_base_url: https://api.slashid.com
subject_header_with_token: Authorization
subject_strip_after_exchange: true
actor_mode: oauth_client
oauth_client_id: <oauth2 client id>
oauth_client_secret: <oauth2 client secret>
requested_token_type: urn:ietf:params:oauth:token-type:jwt
output_header: Authorization
output_prefix: "Bearer "
upstream_request_timeout: 5s
# actor_mode = gate_svid via the SPIRE Workload API
- type: token-exchange
parameters:
slashid_base_url: https://api.slashid.com
subject_header_with_token: Authorization
subject_strip_after_exchange: true
actor_mode: gate_svid
actor_svid_source: workload_api
actor_workload_api_socket: unix:///run/spire/sockets/agent.sock
actor_workload_api_audience: https://api.slashid.com/oauth2/tokens
oauth_client_id: <oauth2 client id, when subject is opaque>
oauth_client_secret: <oauth2 client secret>
requested_token_type: urn:ietf:params:oauth:token-type:jwt
output_header: Authorization
output_prefix: "Bearer "
upstream_request_timeout: 5s
# actor_mode = request_actor (caller provides the actor token in a header)
- type: token-exchange
parameters:
slashid_base_url: https://api.slashid.com
subject_header_with_token: Authorization
subject_strip_after_exchange: true
actor_mode: request_actor
actor_header_with_token: X-Actor-Token
actor_strip_after_exchange: true
requested_token_type: urn:ietf:params:oauth:token-type:jwt
output_header: Authorization
output_prefix: "Bearer "
upstream_request_timeout: 5s
Field reference
SlashID endpoint
slashid_base_url(required) — Base URL of the SlashID API, e.g.https://api.slashid.com. The exchange call goes to<slashid_base_url>/oauth2/tokens.slashid_org_id(optional) — Org id, when needed for the deployment.
Subject (the user the delegated token will act on behalf of)
subject_header_with_token— Header to read the subject token from (e.g.Authorization). Mutually exclusive withsubject_cookie_with_token.subject_cookie_with_token— Cookie name to read the subject token from. Mutually exclusive withsubject_header_with_token.subject_token_type(optional) — Override for the token-type URI sent to the AS. Defaults to inferred from the token shape: a value starting witheyJis treated asurn:ietf:params:oauth:token-type:jwt, anything else asurn:ietf:params:oauth:token-type:access_token.subject_strip_after_exchange(defaulttrue) — Remove the subject token from the request before forwarding it to the upstream.
Actor
actor_mode(required) — One ofoauth_client,gate_svid,request_actor. See Modes above.actor_svid_source— Whenactor_mode=gate_svid, where the SVID comes from. One offile,env,workload_api.actor_svid_file_path— Whenactor_svid_source=file, path to a file containing the raw JWT-SVID. Typically populated by a sidecar likespiffe-helper.actor_svid_file_reload(default30s) — How often Gate stats the SVID file and reloads when the mtime changes.actor_svid_env_var— Whenactor_svid_source=env, environment variable containing the JWT-SVID.actor_workload_api_socket— Whenactor_svid_source=workload_api, the SPIRE agent UDS, e.g.unix:///run/spire/sockets/agent.sock.actor_workload_api_audience— Audience requested when callingFetchJWTSVID. The SlashID AS expects<slashid_base_url>/oauth2/tokenswhen present.actor_header_with_token— Whenactor_mode=request_actor, header to read the actor token from. Mutually exclusive withactor_cookie_with_token.actor_cookie_with_token— Whenactor_mode=request_actor, cookie to read the actor token from. Mutually exclusive withactor_header_with_token.actor_strip_after_exchange(defaulttrue) — Inrequest_actormode, remove the actor header/cookie from the request before forwarding it.
OAuth2 caller client
Required when the subject is an opaque SlashID access token (RFC 6749
binding) and when actor_mode=oauth_client. Optional otherwise.
oauth_client_id— The SlashID OAuth2 client id.oauth_client_secret— Corresponding client secret. Sent as HTTP Basic auth on the exchange call.
Exchange request shape
requested_token_type(required) — Output token format, one ofurn:ietf:params:oauth:token-type:jwtorurn:ietf:params:oauth:token-type:access_token.scope(optional) — Space-delimited scopes to narrow the delegated token. Applies to opaque output only.audience(optional) — Array of audiences to bind into the delegated token (opaque output only).resource(optional) — Array of resource URIs per RFC 8707 (opaque output only).requested_expires_in(optional) — Requested lifetime in seconds; the AS may clamp this against its own ceiling.
Output and transport
output_header(defaultAuthorization) — Header Gate writes the delegated token into on the proxied request.output_prefix(default"Bearer ") — Prefix prepended to the delegated token in the output header. Include the trailing space if you want it.upstream_request_timeout(default5s) — Timeout for the call to SlashID's/oauth2/tokensendpoint.
To learn more about configuring Gate, please visit the configuration page and the plugins section.
Order of plugins in configuration determines their execution order.
Disabling plugin for specific URLs
You can enable or disable this plugin for specific URLs by using the enabled
option in the URLs
configuration.
- Environment variables
- HCL
- JSON
- TOML
- YAML
GATE_URLS_0_PATTERN=svc-example.com/*
GATE_URLS_0_TARGET=http://example:8080
GATE_URLS_1_PATTERN=svc-another-example.com/
GATE_URLS_1_TARGET=https://another-example:8080
gate = {
urls = [
{
pattern = "svc-example.com/*"
target = "http://example:8080"
},
{
pattern = "svc-another-example.com/"
target = "https://another-example:8080"
}
]
// ...
}
{
"gate": {
"urls": [
{
"pattern": "svc-example.com/*",
"target": "http://example:8080",
},
{
"pattern": "svc-another-example.com/",
"target": "https://another-example:8080"
}
],
// ...
URL are matched in the order they are defined in the configuration file.
[[gate.urls]]
pattern = "svc-example.com/*"
target = "http://example:8080"
[[gate.urls]]
pattern = "svc-another-example.com/"
target = "https://another-example:8080"
URL are matched in the order they are defined in the configuration file.
gate:
urls:
- pattern: svc-example.com/*
target: http://example:8080
- pattern: svc-another-example.com/
target: https://another-example:8080
URL are matched in the order they are defined in the configuration file.
Output
On success, Gate replaces the configured output header (default
Authorization) on the proxied request with <output_prefix><access_token>
— so by default the upstream sees Authorization: Bearer <delegated token>.
The delegated token's payload contains the RFC 8693 act claim with
the actor's identifier (the SlashID person id, OAuth2 client id, or SPIFFE
ID, depending on mode), preserving the on-behalf-of relationship for audit
and downstream authorization.
Error mapping
| Condition | Gate response |
|---|---|
| Missing subject token (configured header/cookie empty) | 401 Unauthorized |
actor_mode: request_actor and the inbound actor header/cookie is empty | 401 Unauthorized |
actor_mode: gate_svid or oauth_client and Gate can't materialise the actor (broken SVID source, client_credentials mint rejected, SPIRE agent unreachable, …) | 502 Bad Gateway |
SlashID /oauth2/tokens returns 4xx/5xx, or its response is malformed | 502 Bad Gateway |