Keycloak Token Exchange, Hands-On

How standard token exchange (RFC 8693) works in Keycloak 26, why the audience is everything, and a Docker lab that shows the main success and failure cases on the wire.

Sooner or later every microservice landscape runs into the same question. A request reaches your API with the user’s access token, and your API now has to call another API on that user’s behalf. Which token do you send?

There are three usual answers:

  1. Forward the incoming token. It’s easy, and usually wrong. That token was issued for your API. If it’s addressed only to your API and the downstream API accepts it anyway, the downstream API’s audience validation is missing or wrong. And if it legitimately lists several audiences, forwarding it hands every recipient a credential that also works at the others.
  2. Use client credentials. Your service gets a token of its own. The downstream API now knows which service is calling, but not which user the call is for, so user-level authorization ends up in your code.
  3. Exchange the token. Your service asks Keycloak: “Here’s the token I received. Give me one for that other API, for the same user, with only what that API needs.” That is OAuth 2.0 Token Exchange, RFC 8693.

This post covers option 3 in Keycloak 26: how standard token exchange works, how to configure it, and what it looks like on the wire. Every token and every error message below comes from a small lab you can run on your laptop: Keycloak in Docker, configured with Terraform.

Tested with Keycloak 26.8.1 and the Terraform provider keycloak/keycloak 5.10.0.

The flow#

The Keycloak docs use three roles: the initial client the user logs in to, the requester client that performs the exchange, and the target client the new token is for. Here they are as a small shop:

sequenceDiagram
    autonumber
    actor Alice
    participant Web as shop-web
    participant Order as order-api
    participant KC as Keycloak
    participant Inv as inventory-api

    Alice->>Web: Log in
    Web->>KC: Authorization code flow
    KC-->>Web: Access token<br/>aud: order-api
    Web->>Order: GET /orders/42<br/>Bearer token
    Order->>KC: Token exchange<br/>audience=inventory-api
    KC-->>Order: New access token<br/>aud: inventory-api
    Order->>Inv: GET /stock<br/>Bearer new token
    Inv-->>Order: 200 OK
    Order-->>Web: 200 OK

The lab realm contains these clients:

Role in the exchangeClientTypeWhat matters
Initial clientshop-webpublicRequests the optional client scope order-api, whose audience mapper puts order-api into aud
Requester clientorder-apiconfidentialStandard Token Exchange switched on; full scope off
Target clientsinventory-api, payment-apiconfidential, no flowsOwn the client roles stock:read and payment:create
Off-limits targetloyalty-apiconfidential, no flowsorder-api has no scope mapping for it
Unrelated appbackoffice-webpublicIts tokens are not meant for order-api

Standard (V2) vs. legacy (V1) token exchange#

If you find older blog posts or Stack Overflow answers about Keycloak token exchange, they most likely describe the legacy implementation: the token-exchange preview feature, fine-grained admin permissions on the target client, and a policy that allows the exchange. Keycloak 26.2 introduced standard token exchange, which follows RFC 8693 closely and is fully supported. The differences that matter in practice:

Standard token exchange (V2)Legacy token exchange (V1)
StatusSupported, on by defaultPreview, behind feature flags
How you allow itClient switch + subject token audienceFine-grained admin permissions (v1)
audienceSeveral values; filters (downscopes) the tokenOne value; token issued to that client
scopeOptional client scopes of the requesterClient scopes of the target
Public clientsNot allowedCould exchange their own tokens
Default requested_token_typeAccess tokenRefresh token
Impersonation, external tokensNot covered (see below)Preview

The scope row trips people up when migrating: in V2 the requester’s client scopes decide the content of the new token, not the target’s.

The lab#

The lab is on GitHub: iam-lab/keycloak-token-exchange. It runs Keycloak 26.8.1 in Docker and provisions the realm with Terraform, so every setting in this post is a few lines of HCL you can read and reuse. You need Docker, curl and jq. Terraform runs in a container.

git clone https://github.com/fmiskovic/iam-lab.git
cd iam-lab/keycloak-token-exchange

docker compose up -d --wait          # start Keycloak, wait until it is healthy
docker compose run --rm terraform    # provision the demo realm
./exchange.sh                        # run every step of this post in one go

Terraform creates all clients from the table above, plus one user, alice (password alice), who has the client roles stock:read, payment:create and points:read. The APIs from the diagram don’t actually run: the clients are their representations in Keycloak, and exchange.sh plays order-api’s part with curl.

Three settings make it work#

1. The requester may exchange tokens. order-api is a confidential client with standard token exchange switched on. In the admin console this is Clients → order-api → Settings → Capability config → Standard Token Exchange. In Terraform it’s one argument (clients.tf):

resource "keycloak_openid_client" "order_api" {
  realm_id      = keycloak_realm.demo.id
  client_id     = "order-api"
  access_type   = "CONFIDENTIAL"
  client_secret = var.order_api_secret

  standard_token_exchange_enabled = true
  full_scope_allowed              = false
}

Under the hood, that’s the client attribute standard.token.exchange.enabled, in case you manage clients through the Admin REST API or realm imports.

2. The subject token is meant for the requester. Keycloak only lets order-api exchange tokens whose aud claim contains order-api. An Audience mapper adds it. The mapper lives in an optional client scope named order-api, so shop-web gets the audience only when it asks for it with scope=order-api, not in every token it receives:

resource "keycloak_openid_client_scope" "order_api_audience" {
  realm_id               = keycloak_realm.demo.id
  name                   = "order-api"
  include_in_token_scope = true
}

resource "keycloak_openid_audience_protocol_mapper" "order_api_audience" {
  realm_id                 = keycloak_realm.demo.id
  client_scope_id          = keycloak_openid_client_scope.order_api_audience.id
  name                     = "audience: order-api"
  included_client_audience = keycloak_openid_client.order_api.client_id
  add_to_access_token      = true
  add_to_id_token          = false
}

resource "keycloak_openid_client_optional_scopes" "shop_web" {
  realm_id        = keycloak_realm.demo.id
  client_id       = keycloak_openid_client.shop_web.id
  optional_scopes = [keycloak_openid_client_scope.order_api_audience.name]
}

Without the scope, shop-web’s token has no aud at all, and the exchange fails the same way as in Step 5. A client that doesn’t have the scope assigned can’t get it either: backoffice-web asking for scope=openid order-api gets invalid_scope. Note that keycloak_openid_client_optional_scopes is authoritative: it replaces Keycloak’s default optional scopes (address, phone, offline_access, …) on shop-web, which it doesn’t need anyway.

3. The requester may reach the target. order-api has Full scope allowed switched off, with explicit role scope mappings for inventory-api → stock:read and payment-api → payment:create only (roles.tf):

resource "keycloak_generic_role_mapper" "order_api" {
  for_each = toset(["stock_read", "payment_create"])

  realm_id  = keycloak_realm.demo.id
  client_id = keycloak_openid_client.order_api.id
  role_id   = keycloak_role.client[each.key].id
}

Keycloak’s built-in roles client scope contains the Audience Resolve mapper, which adds a client to aud whenever the token carries one of that client’s roles. In this lab that’s the only source of audiences for order-api’s tokens, so the audiences an exchanged token can have are:

the user’s roles ∩ the requester’s role scope mappings → their clients become available audiences

An Audience mapper in one of the requester’s client scopes is a second source: it makes its audience available regardless of roles. With a hardcoded loyalty-api mapper on order-api, the request in Step 4 succeeds, with an empty resource_access. When an audience shows up unexpectedly, check both.

Step 1: The token Alice brings#

Log in as Alice through shop-web and decode the payload:

TOKEN_URL=http://localhost:8080/realms/demo/protocol/openid-connect/token

USER_TOKEN=$(curl -fsS "$TOKEN_URL" \
  -d client_id=shop-web \
  -d grant_type=password \
  -d username=alice -d password=alice \
  --data-urlencode "scope=openid order-api" | jq -r .access_token)
{
  "iss": "http://localhost:8080/realms/demo",
  "sub": "0dc01acb-30b7-43f8-a8bf-b2532319bf59",
  "preferred_username": "alice",
  "azp": "shop-web",
  "aud": "order-api",
  "scope": "openid email profile order-api",
  "resource_access": null,
  "sid": "GaFjU5KA7kAWnQBrYAxlzSoY"
}

The script filters the payload to the claims that matter here, so null means not present. Your IDs will differ.

  • sub is Alice’s user ID in this realm. Treat it as an opaque string and key users on iss and sub together: that pair is the only identifier OpenID Connect guarantees to be stable and unique. Her username is in preferred_username, which the profile scope adds. Usernames can be renamed and reused, so use it for display and logs only.
  • azp (authorized party) is the client the token was issued to: shop-web.
  • aud is who should accept it: only order-api, because shop-web asked for the order-api scope.
  • There are no roles. shop-web has Full scope allowed switched off, so the frontend’s token doesn’t carry permissions for downstream APIs it never calls itself.

Step 2: Exchange it for an inventory-api token#

order-api now authenticates as itself and hands Alice’s token to Keycloak:

curl -sS "$TOKEN_URL" \
  -u order-api:order-api-secret \
  --data-urlencode grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  --data-urlencode subject_token_type=urn:ietf:params:oauth:token-type:access_token \
  -d subject_token="$USER_TOKEN" \
  -d audience=inventory-api
ParameterValueNotes
client authentication-u order-api:order-api-secretHTTP Basic here. Any authentication method configured for the client works (signed JWT, mTLS, …).
grant_typeurn:ietf:params:oauth:grant-type:token-exchange
subject_tokenAlice’s tokenMust be an access token issued by the same realm.
subject_token_typeurn:ietf:params:oauth:token-type:access_tokenRequired. V2 only accepts access tokens.
requested_token_type(omitted)Defaults to an access token.
audienceinventory-apiA client ID. Repeat the parameter to request several.
scope(omitted)Requests optional client scopes of order-api.

The response (access token removed for readability):

{
  "expires_in": 300,
  "refresh_expires_in": 0,
  "token_type": "Bearer",
  "not-before-policy": 0,
  "session_state": "GaFjU5KA7kAWnQBrYAxlzSoY",
  "scope": "email profile",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token"
}

And the new token’s payload:

{
  "iss": "http://localhost:8080/realms/demo",
  "sub": "0dc01acb-30b7-43f8-a8bf-b2532319bf59",
  "preferred_username": "alice",
  "azp": "order-api",
  "aud": "inventory-api",
  "scope": "email profile",
  "resource_access": {
    "inventory-api": {
      "roles": ["stock:read"]
    }
  },
  "sid": "GaFjU5KA7kAWnQBrYAxlzSoY"
}

Side by side:

ClaimSubject tokenExchanged token
sub0dc01acb…0dc01acb… (same user)
preferred_usernamealicealice
sidGaFjU5KA…GaFjU5KA… (same session)
azpshop-weborder-api
audorder-apiinventory-api
scopeopenid email profile order-apiemail profile
resource_accessnoneinventory-api: stock:read

What to notice:

  • Same user, same session. inventory-api can make decisions about Alice, not about “some service”.
  • azp is now order-api. The target can see which client asked for the token. This is not the RFC 8693 act (actor) claim, though; more on that at the end.
  • scope is recalculated, not copied. It comes from order-api’s default client scopes, which is why openid and order-api are gone.
  • No refresh token (refresh_expires_in: 0). When the token expires, order-api exchanges again.

Step 3: Forget the audience, get a skeleton key#

Run the same exchange without audience:

{
  "azp": "order-api",
  "aud": ["inventory-api", "payment-api"],
  "resource_access": {
    "inventory-api": { "roles": ["stock:read"] },
    "payment-api": { "roles": ["payment:create"] }
  }
}

Without audience, Keycloak doesn’t filter: you get every audience the requester could reach. order-api sends this token to inventory-api, but the token is just as valid at payment-api. If the inventory service logs tokens, or gets compromised, someone can replay Alice’s token there and create payments in her name.

RFC 8693 and the Keycloak docs agree: downscope as far as you can, ideally to a single audience. Always send audience, once per downstream API.

Step 4: Asking for more than you’re allowed#

Alice does have the loyalty-api role points:read. What happens when order-api asks for a loyalty token?

curl -sS "$TOKEN_URL" -u order-api:order-api-secret \
  --data-urlencode grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  --data-urlencode subject_token_type=urn:ietf:params:oauth:token-type:access_token \
  -d subject_token="$USER_TOKEN" \
  -d audience=loyalty-api
{
  "error": "invalid_request",
  "error_description": "Requested audience not available: loyalty-api"
}

audience filters; it never adds. order-api has no role scope mapping for loyalty-api, so that audience isn’t available to it, whatever roles Alice has. If you request several audiences and any of them is unavailable, the whole request fails.

Step 5: Exchanging a token that isn’t yours#

Alice also uses backoffice-web, whose tokens have nothing to do with order-api:

{
  "azp": "backoffice-web",
  "aud": null,
  "scope": "openid email profile"
}

If order-api gets hold of that token somehow and tries to exchange it:

{
  "error": "access_denied",
  "error_description": "Client is not within the token audience"
}

This is the core authorization rule of standard token exchange: a client can only exchange tokens that were addressed to it. A token leaked from somewhere else can’t be laundered through your service into a token for something new. The one exception: a client may exchange a token that was issued to itself, for example to narrow down its own token.

Other errors you’ll run into#

All of these were produced against the lab:

error_descriptionCause
Standard token exchange is not enabled for the requested clientThe switch is off on the requester client. A public client like shop-web gets the same error: only confidential clients can use standard token exchange.
Parameter 'subject_token_type' required for standard token exchangeV2 requires subject_token_type.
requested_token_type unsupportedYou asked for a refresh token, but the client doesn’t allow refresh tokens from token exchange.
Requested audience not available: <client>Step 4: the requester’s scope doesn’t cover that audience.
Client is not within the token audienceStep 5: the subject token’s aud doesn’t contain the requester.

Scope vs. audience#

The two parameters look similar but do opposite things:

  • audience removes. It filters the audiences (and their client roles) that the requester’s client scopes would produce. It never adds any.
  • scope adds. It requests optional client scopes of the requester, as with any other grant. That can bring in extra claims or roles, within what the requester is allowed to have.

If the result surprises you, open Clients → order-api → Client scopes → Evaluate in the admin console. It shows what a token would contain for a given user and set of scopes, which is the quickest way to debug “why is this audience (not) in my token?”.

Refresh tokens and ID tokens#

By default you get an access token and no refresh token. A requester client can be allowed to receive refresh tokens: Clients → order-api → Advanced → OpenID Connect Compatibility Modes → Allow refresh token in Standard Token Exchange, set to Same session. The Keycloak docs still recommend exchanging for access tokens where possible, and refresh tokens aren’t supported at all for subject tokens from offline sessions.

You can also request urn:ietf:params:oauth:token-type:id_token. That’s rarely what a service-to-service call needs.

What inventory-api has to check#

Token exchange only protects you if the target API validates the token properly:

  1. Signature against the realm’s JWKS, and iss exactly matching the realm URL.

  2. exp (and nbf, if present).

  3. aud contains inventory-api. This is what makes a forwarded order-api token bounce. Many frameworks skip it unless you configure it. In Spring Boot, for example, it’s one property:

    spring:
      security:
        oauth2:
          resourceserver:
            jwt:
              issuer-uri: http://localhost:8080/realms/demo
              audiences:
                - inventory-api
    
  4. Roles from resource_access.inventory-api.roles, not from some other client’s entry.

  5. Optionally, an allow-list for azp: inventory-api only accepts exchanged tokens from callers it knows.

Production checklist#

  • Enable standard token exchange only on clients that need it. Treat it like enabling service accounts.
  • Switch off Full scope allowed on requester clients and add explicit role scope mappings.
  • Put the requester’s audience mapper in an optional client scope, so frontends carry that audience only when they ask for it.
  • Always send audience, one per downstream API.
  • Cache exchanged tokens so you don’t call Keycloak on every request, but reuse one only for an equivalent request: same requester, user session, audience and requested scopes, and the same key if tokens are sender-constrained. Refresh shortly before they expire.
  • Keep access token lifespans short. Revoking the subject token doesn’t revoke exchanged access tokens.
  • Prefer strong client authentication for requesters (signed JWT or mTLS) over shared secrets. Sender-constrained tokens (DPoP, mTLS) are supported in V2.
  • Use client policies if you need extra rules around who may exchange what.
  • Watch the TOKEN_EXCHANGE events in Keycloak’s event log.

What standard token exchange doesn’t do#

Standard token exchange covers internal-to-internal exchange within one realm. For everything else:

  • Impersonation (getting a token for a different user) isn’t implemented in V2. It only exists in the legacy preview.
  • External to internal (a token from another identity provider in, a Keycloak token out): see the JWT Authorization Grant and identity chaining across domains in the Keycloak docs.
  • Internal to external (a Keycloak token in, a token for an external IdP out): use the identity brokering APIs.
  • Delegation with an act claim per RFC 8693 is a preview. Keycloak 26.8 added consent-based client delegation, aimed at AI agents and automation: the user’s token gets a may_act claim through the delegation:client:<client-id> scope, and the exchanged token carries act. Standard token exchange rejects subject tokens with delegation claims, so the two paths don’t mix. That’s a post of its own.
  • The RFC 8693 resource parameter isn’t supported yet.

Wrapping up#

Token exchange makes services stop passing around one all-powerful user token. It hands each hop a token that carries only what the next hop needs. In Keycloak 26 it comes down to three settings: the switch on the requester, the requester in the subject token’s audience, and tight role scope mappings on the requester. And always send audience.

Clean up the lab with docker compose --profile tools down -v.

References#

Tested with Keycloak 26.8.1 and the Terraform provider keycloak/keycloak 5.10.0.