Skip to main content
Guides

Token exchange (RFC 8693)

Resolve per-user credentials dynamically by calling an external Security Token Service (STS). Multiple callers with different identities share a single gatekeeper instance. Each request triggers a token exchange — or uses a cached token — scoped to the caller’s identity.

This guide covers gatekeeper configuration and the STS endpoint contract. Implement the STS endpoint yourself; gatekeeper is the client.

Prerequisites

  • CA certificate generated (CA Setup)
  • An STS endpoint that implements RFC 8693 token exchange (see STS Endpoint Requirements below)
  • Client credentials (client_id and client_secret) for authenticating gatekeeper to the STS

How it works

  1. A request arrives at gatekeeper with a subject identity (via header or proxy auth username).
  2. Gatekeeper checks its cache for a valid token for that subject.
  3. On cache miss, gatekeeper sends an RFC 8693 POST to the STS with the subject token, client credentials, and optional resource/actor parameters.
  4. The STS returns an access_token (and optional expires_in).
  5. Gatekeeper caches the token and injects it into the upstream request.

Subject identity modes

Gatekeeper extracts the subject identity from one of two sources. The two modes are mutually exclusive.

Mode 1: Subject from request header

The subject identity is read from a named HTTP header on each request. Gatekeeper strips the header before forwarding upstream.

credentials:
  - host: api.github.com
    grant: github
    prefix: Bearer
    source:
      type: token-exchange
      endpoint: https://sts.example.com/token
      client_id: gk-client
      client_secret_env: STS_CLIENT_SECRET
      subject_header: X-Gatekeeper-Subject
      resource: https://api.github.com

The client includes the subject in each request:

curl --proxy http://127.0.0.1:9080 --cacert ca.crt \
  -H "X-Gatekeeper-Subject: alice@example.com" \
  https://api.github.com/user

Mode 2: Subject from proxy auth

The subject identity is extracted from the username in proxy authentication credentials. No request headers are modified.

credentials:
  - host: api.github.com
    grant: github
    prefix: Bearer
    source:
      type: token-exchange
      endpoint: https://sts.example.com/token
      client_id: gk-client
      client_secret_env: STS_CLIENT_SECRET
      subject_from: proxy-auth
      resource: https://api.github.com

The client encodes the subject in the proxy URL. Percent-encode @ as %40:

export HTTP_PROXY="http://alice%40example.com:proxypass@127.0.0.1:9080"
curl --cacert ca.crt https://api.github.com/user

Configuration reference

FieldRequiredDefaultDescription
endpointYes—STS token endpoint URL
client_idYes—OAuth client ID for HTTP Basic auth to STS
client_secretOne of—Client secret (literal value)
client_secret_envOne of—Environment variable containing the client secret
subject_headerOne of—Request header to extract subject from (stripped before forwarding)
subject_fromOne of—Set to proxy-auth to extract subject from proxy auth username
subject_token_typeNourn:ietf:params:oauth:token-type:access_tokenToken type URI for the subject token
resourceNo—Target resource URI sent to the STS
actor_token_fromNo—Set to proxy-auth-password to forward the proxy auth password as actor token
actor_token_typeNourn:ietf:params:oauth:token-type:access_tokenToken type URI for the actor token
bot_subjectNo"" (disabled)A sentinel subject value that falls through to the next credential rule instead of an STS exchange — see Bot/service fallback below
static_fallbackNoautoexplicit limits the fallthrough to static credentials the client names by header, and refuses the request with 403 otherwise — see below

Bot/service fallback

An empty subject already falls through to the next matching credential rule for a host instead of calling the STS — getCredentialsForRequest (proxy/inject.go) treats a resolver’s (nil, nil) return as “try the next rule,” which is how a per-host github-app (bot) credential is meant to take over when there is no per-user identity for the STS to exchange. This is what makes the common two-rule pattern work: a token-exchange rule for real users, then a github-app rule beneath it as the bot fallback.

A caller building the proxy-auth URL with undici’s ProxyAgent (Node.js) cannot express that fallback cleanly: undici only sends Proxy-Authorization when the proxy URL’s username is non-empty (username && password, no password-only branch) — an empty-username proxy URL, the natural way to say “no subject, use the bot,” silently sends no proxy auth at all, which most gatekeeper deployments require.

bot_subject gives the same fallthrough behavior a non-empty, dedicated sentinel value, so a caller can encode “use the bot” as an ordinary (non-empty) proxy-auth username:

credentials:
  - host: api.github.com
    grant: github-user
    source:
      type: token-exchange
      endpoint: https://sts.example.com/token
      client_id: gk-client
      client_secret_env: STS_CLIENT_SECRET
      subject_from: proxy-auth
      bot_subject: "-"
      resource: https://api.github.com

  - host: api.github.com
    grant: github-bot
    source:
      type: github-app
      app_id: "123456"
      installation_id: "789012"
      private_key_env: GITHUB_APP_PRIVATE_KEY
# Real user: subject_from proxy-auth reads "alice@example.com" and exchanges it.
HTTP_PROXY="http://alice%40example.com:ak_alice_xxxxx@127.0.0.1:9080"

# Bot/service traffic: the sentinel subject skips the STS and falls through
# to the github-app rule above, with a non-empty proxy-auth username the
# whole way -- undici's Proxy-Authorization check passes.
HTTP_PROXY="http://-:proxy-token@127.0.0.1:9080"

bot_subject is unset by default, so this is opt-in and fully backward compatible: a config written before this field existed keeps exchanging every non-empty subject exactly as before. When set, pick a value that can never collide with a real subject your deployment could see — "-" is a safe default because no CF-Access email, GitHub username, or similar identity string is ever a bare hyphen.

bot_subject requires subject_from: proxy-auth and gatekeeper enforces that, not merely documents it. Setting bot_subject on a subject_header-mode source is a config-load error ("bot_subject requires subject_from 'proxy-auth'"). This is not incidental: subject_header mode reads a caller-controlled, self-asserted request header with nothing authenticating it, unlike subject_from: proxy-auth. Without this restriction, any caller could simply send the configured sentinel value in that header to skip the STS and fall through to the credential below — typically broader, e.g. a github-app bot rule — bypassing per-subject authentication for the host entirely. The check is enforced twice: once at config load (rejecting the combination outright) and once again at request time (the fallthrough only ever fires when the resolver is actually in proxy-auth mode), so the property holds even for a resolver constructed outside the normal config-loading path.

Set static_fallback: explicit when the static rule on the host is not a valid stand-in for the per-user credential, for example a per-user subscription rule beside a static API key. With no token from the exchange, gatekeeper then injects only a static credential whose header the client sent itself. With none, it answers 403 with X-Gatekeeper-Blocked: credential-required and a message naming the grant and host, and logs the denial with the request’s user_id. A 404 from the STS (“no credential for this subject”) is refused the same way, and the message ends with the STS’s error_description.

The sentinel subject is exactly what shows up as user_id on the canonical log line, since user_id is populated from the same proxy-auth username the resolver reads. That’s a strict readability improvement over the empty-subject case: user_id="-" reads unambiguously as “bot/service traffic,” where an absent user_id field (the empty-subject case) gives no positive signal at all.

Actor token forwarding

By default, subject identities are self-asserted — any caller can claim any identity. In shared environments, use actor token forwarding to let the STS verify that the caller is an authorized gatekeeper client.

Configure gatekeeper to forward the proxy auth password as the RFC 8693 actor_token. actor_token_from requires proxy.auth_token or proxy.auth_token_env; gatekeeper fails at config load without one, naming the credential host.

proxy:
  auth_token_env: GATEKEEPER_PROXY_AUTH_TOKEN

credentials:
  - host: api.github.com
    grant: github
    prefix: Bearer
    source:
      type: token-exchange
      endpoint: https://sts.example.com/token
      client_id: gk-client
      client_secret_env: STS_CLIENT_SECRET
      subject_from: proxy-auth
      actor_token_from: proxy-auth-password
      resource: https://api.github.com

Each caller sends the shared proxy token as the proxy auth password and its subject as the username:

HTTP_PROXY=http://alice%40example.com:shared-proxy-token@127.0.0.1:9080

Gatekeeper sends both subject_token=alice@example.com and actor_token=shared-proxy-token to the STS. The STS validates the actor token before issuing tokens.

When actor_token_from is configured on any credential, gatekeeper requires all clients to provide Basic proxy auth with a non-empty password. A password that does not equal proxy.auth_token/auth_token_env is rejected with 407 before it reaches the STS — see proxy.auth_token. Gatekeeper has no per-caller password mode: with no shared token, any non-empty password would receive every static credential on non-token-exchange hosts, so the config is rejected.

Caching behavior

Gatekeeper caches tokens per (subject_token, actor_token) pair:

  • If expires_in is returned by the STS, the token is cached until expiry, capped at 1 minute.
  • If expires_in is 0 or omitted, the cap is used.
  • Concurrent requests for the same subject are coalesced into a single STS call via singleflight.
  • Expired entries are evicted lazily on the next exchange.
  • There is no proactive refresh. When a cached token expires, the next request triggers a new exchange.
  • When the destination returns 401, 403, or 429, the cache entry is dropped so the next request exchanges afresh. A 429 may indicate that the selected backing credential exhausted its allowance even though its token remains valid. The failed request is not retried. Evictions are rate-limited to one per key per 10 seconds.

The cap exists because a long expires_in only means the token may live that long, not that it stays valid. The upstream credential behind the exchange can be revoked, rotated, or re-authorized at any moment, and gatekeeper has no way to learn of it. Honoring a multi-hour expires_in meant a rotated credential kept being injected — and kept being rejected — for hours.

A consequence: expires_in values above the cap no longer reduce STS request volume. Sizing the STS for roughly one exchange per subject per minute is the safe assumption.

STS endpoint requirements

Gatekeeper sends a POST with Content-Type: application/x-www-form-urlencoded and HTTP Basic authentication.

Request format

POST /token HTTP/1.1
Host: sts.example.com
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded

grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=alice%40example.com&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&resource=https%3A%2F%2Fapi.github.com

Success response (HTTP 200)

{
  "access_token": "gho_exchanged_abc123",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 3600
}
FieldTypeRequiredDescription
access_tokenstringYesThe token gatekeeper injects upstream
issued_token_typestringNoToken type URI of the issued token
token_typestringNoInformational; gatekeeper uses its own prefix config
expires_inintNoTTL in seconds, capped at 60. Defaults to the cap if omitted
gatekeeper_upgradeobjectNoUpgrade hint for the client; see below

access_token must be non-empty. Gatekeeper treats an empty value as an error.

Decline response (HTTP 400)

To send the request out with the host’s configured static credential instead, the STS declines:

{
  "error": "invalid_target",
  "gatekeeper_fallback": "default",
  "gatekeeper_upgrade": {"route": "gcp", "entitlements": ["gcp-deploy-dev"]}
}

Gatekeeper injects the static credentials for the same host, or forwards the client’s headers when there are none, and caches the decline for 1 minute. A 401, 403, or 429 from the destination drops it. A bare invalid_target still fails the request.

On a 401 or 403 from the destination, gatekeeper sends gatekeeper_upgrade from a success or a decline to the client as X-Gatekeeper-Upgrade: entitlements=("gcp-deploy-dev"), route=("gcp"), and removes any value the destination sent. Values are strings or arrays of strings; a hint of any other shape is ignored.

Error response (other non-200)

Gatekeeper treats any other non-200 status as a failure and returns HTTP 502 to the client. Use standard OAuth error format:

{
  "error": "invalid_grant",
  "error_description": "Subject token is expired or revoked"
}

Note: Do not echo actor_token in error responses. Gatekeeper logs up to 200 bytes of STS error bodies, so sensitive values would appear in proxy logs.

Implementation checklist

  • Accept POST with Content-Type: application/x-www-form-urlencoded
  • Validate HTTP Basic auth credentials (client_id / client_secret)
  • Validate grant_type is exactly urn:ietf:params:oauth:grant-type:token-exchange
  • Extract subject_token — the user/caller identity
  • Read resource if present — the target API
  • Look up or mint an access token for the given subject and resource
  • Return JSON with a non-empty access_token
  • Set expires_in to enable caching (values above 60s are capped)
  • Return non-200 for invalid/expired/unknown subjects
  • Handle concurrent requests (idempotency or internal locking)
  • (Optional) Validate actor_token as the gatekeeper proxy token

Testing

Test your STS endpoint with curl:

curl -X POST https://sts.example.com/token \
  -u "gk-client:your-secret" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token=alice&subject_token_type=urn:ietf:params:oauth:token-type:access_token&resource=https://api.github.com"

Then test through the proxy:

curl --cacert ca.crt --proxy http://127.0.0.1:9080 \
  -H "X-Gatekeeper-Subject: alice" \
  https://api.github.com/user

The proxy log shows credential injection with the grant name.

Next steps