Keycloak Token Exchange: Practical Implementation Guide

Guilliano Molaire Guilliano Molaire Updated September 30, 2026 10 min read

Token exchange lets a service trade a token it already holds for a different token with a narrower audience, different scopes, or a different token type. It is defined in RFC 8693, and the most common reason to use it is a microservice that receives a user’s access token and needs to call another service on that user’s behalf without forwarding a token that carries more access than the downstream service should see.

In Keycloak 26.2 and later, standard token exchange (often called V2) is fully supported and switched on by default at the server level, so using it comes down to a per-client setting and getting the audience right. This guide walks through that setup, shows working Node.js and Java code, and explains what changed for the older use cases such as impersonation and external tokens. If you want the concepts first, including how V2 compares with the legacy V1 implementation, read how Keycloak token exchange works and come back here for the implementation.

Which token exchange your Keycloak version runs

Keycloak has two implementations, and most older tutorials (including earlier versions of this one) describe the legacy one.

Standard token exchange (V2) Legacy token exchange (V1)
Status in Keycloak 26.x Supported and on by default since 26.2 Preview, and marked deprecated since 26.6
Server feature token-exchange-standard:v2 (no flag needed) token-exchange, plus admin-fine-grained-authz:v1
What it exchanges An internal access token for another internal token in the same realm Internal, external, and impersonation exchanges
How a client is allowed to exchange A switch on the requester client, and the client must be in the subject token’s aud claim Fine-grained admin permissions V1 on the target client

The rest of this guide uses V2. The sections near the end cover the V1-only use cases and their current replacements.

Enabling token exchange for a client

Because the server feature is already on, you enable standard token exchange on the client that sends exchange requests, which Keycloak calls the requester client.

  1. In the admin console, open the requester client. It has to be a confidential client, meaning Client authentication is on, because public clients are not allowed to exchange tokens.
  2. On the Settings tab, in Capability config, turn on Standard Token Exchange and save.
  3. Make sure the user tokens that reach the requester client list it in their aud claim. The usual way is an Audience mapper on a client scope of the client that signs the user in, or a client role of the requester client that the user holds. Keycloak rejects an exchange when the requester client is not in the subject token’s audience, unless the client is exchanging a token that was issued to itself.
  4. If the requester client needs a refresh token back from the exchange, open its Advanced tab and set Allow refresh token in Standard Token Exchange under OpenID Connect Compatibility Modes to Same session. The default is No, and Keycloak never issues offline_access through an exchange.

For local testing you can run Keycloak in dev mode without any feature flags:

docker run -p 8080:8080 
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin 
  -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin 
  quay.io/keycloak/keycloak:26.7.5 start-dev

If you prefer Compose, the Keycloak Docker Compose Generator produces a working setup, and nothing extra is needed for standard token exchange.

The token exchange request

Every exchange is a form POST to the realm’s token endpoint with grant_type=urn:ietf:params:oauth:grant-type:token-exchange. The requester client authenticates with its own credentials in whatever way it is configured, such as a client secret, a signed JWT, or mTLS.

Parameter Required What it does in standard token exchange
subject_token Yes The user’s access token that the requester client received
subject_token_type Yes Must be urn:ietf:params:oauth:token-type:access_token, the only type V2 accepts
requested_token_type No Defaults to an access token; can also be a refresh token or an ID token
audience No One or more client_id values; narrows the new token to only those audiences
scope No Adds optional client scopes of the requester client to the new token

The audience parameter only ever removes audiences. It cannot add an audience that the user’s roles and the client’s scopes would not already produce, and if you ask for an audience the user has no access to, Keycloak rejects the request, so it is best to request a single audience.

Example: calling a downstream service on a user’s behalf

Say service-a receives a request with Alice’s access token and needs to call service-b. Instead of forwarding Alice’s token, it exchanges it for one scoped to service-b. Forwarding the original token has three problems: it may carry roles for services that service-b should never see, service-b may reject it because its own client is not in the audience, and nothing in the token tells service-b that the call came through service-a.

With service-a set up as the requester client as described above, the exchange looks like this:

curl -X POST https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token 
  -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" 
  -d "client_id=service-a" 
  -d "client_secret=service-a-secret" 
  -d "subject_token=eyJhbGciOi..." 
  -d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" 
  -d "audience=service-b" 
  -d "requested_token_type=urn:ietf:params:oauth:token-type:access_token"

The response carries an access token whose aud claim is service-b and whose resource_access section only holds service-b roles:

{
  "access_token": "eyJhbGciOi...",
  "token_type": "Bearer",
  "expires_in": 300,
  "scope": "profile email",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token"
}

Paste the new token into the JWT Token Analyzer to check that the audience and roles came out the way you intended.

Node.js implementation

import axios from 'axios';

const KEYCLOAK_URL = 'https://keycloak.example.com';
const REALM = 'myrealm';
const TOKEN_URL = `${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token`;

async function exchangeToken(subjectToken, targetAudience) {
  const params = new URLSearchParams({
    grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
    client_id: 'service-a',
    client_secret: process.env.SERVICE_A_SECRET,
    subject_token: subjectToken,
    subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
    audience: targetAudience,
    requested_token_type: 'urn:ietf:params:oauth:token-type:access_token',
  });

  const response = await axios.post(TOKEN_URL, params, {
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  });

  return response.data.access_token;
}

// Express handler example
async function callServiceB(req, res) {
  const userToken = req.headers.authorization?.replace('Bearer ', '');

  // Exchange the user's token for one scoped to service-b
  const serviceBToken = await exchangeToken(userToken, 'service-b');

  // Call Service B with the exchanged token
  const result = await axios.get('https://service-b.internal/api/data', {
    headers: { Authorization: `Bearer ${serviceBToken}` },
  });

  res.json(result.data);
}

Java implementation with Spring WebClient

import org.springframework.http.MediaType;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;

public class TokenExchangeService {

    private final WebClient webClient;
    private final String tokenUrl;
    private final String clientId;
    private final String clientSecret;

    public TokenExchangeService(String keycloakUrl, String realm,
                                 String clientId, String clientSecret) {
        this.webClient = WebClient.builder().build();
        this.tokenUrl = keycloakUrl + "/realms/" + realm
                        + "/protocol/openid-connect/token";
        this.clientId = clientId;
        this.clientSecret = clientSecret;
    }

    public String exchangeToken(String subjectToken, String targetAudience) {
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type",
                   "urn:ietf:params:oauth:grant-type:token-exchange");
        params.add("client_id", clientId);
        params.add("client_secret", clientSecret);
        params.add("subject_token", subjectToken);
        params.add("subject_token_type",
                   "urn:ietf:params:oauth:token-type:access_token");
        params.add("audience", targetAudience);
        params.add("requested_token_type",
                   "urn:ietf:params:oauth:token-type:access_token");

        var response = webClient.post()
            .uri(tokenUrl)
            .contentType(MediaType.APPLICATION_FORM_URLENCODED)
            .body(BodyInserters.fromFormData(params))
            .retrieve()
            .bodyToMono(TokenResponse.class)
            .block();

        return response.getAccessToken();
    }
}

TokenResponse here is a small class of your own that maps the access_token field of the JSON response.

Impersonation, external tokens, and cross-realm exchange

These three were all part of legacy token exchange, and none of them is covered by standard token exchange in Keycloak 26.x.

Impersonation, where a service obtains a token that simply represents a user, is not implemented in V2. It still works in V1, but only if you start the server with --features=token-exchange,admin-fine-grained-authz:v1, and Keycloak runs a single version of fine-grained admin permissions at a time, so turning on V1 switches off fine-grained admin permissions V2 for the whole server. If the goal is a support engineer seeing what a user sees, the admin console impersonation feature is usually the better fit, and our guide to secure user impersonation in Keycloak covers how to scope and audit it.

External-to-internal exchange, such as trading a Google token from a mobile app’s native sign-in for a Keycloak token, is now handled by the JWT Authorization Grant from RFC 7523, which became fully supported in Keycloak 26.6. We walk through a working setup in JWT authorization grant in Keycloak with an external IdP. The opposite direction, retrieving a linked provider’s token from a Keycloak token, goes through the identity brokering APIs, which work when Keycloak stored the provider’s tokens during login; see Skycloak’s identity providers feature for the setup on a managed cluster.

Cross-realm exchange is not possible with V2, because the requester client and the target audience must be in the same realm. Keycloak’s guide to identity and authorization chaining across domains describes combining standard token exchange in one realm with the JWT Authorization Grant in another to carry a user across trust domains, which is the supported way to build what used to be a cross-realm exchange. For multi-tenant designs it is also worth checking whether one realm with Keycloak Organizations removes the need to cross realms at all.

Error handling

Standard token exchange returns OAuth errors with an error_description that names the exact problem, so log the description rather than only the error code. These are the ones you are most likely to hit, with the descriptions Keycloak 26.x returns:

Error Description Fix
invalid_request Standard token exchange is not enabled for the requested client Turn on Standard Token Exchange on the requester client
access_denied Client is not within the token audience Add the requester client to the subject token’s aud claim with an audience mapper or client role
invalid_client Audience not found The audience value must be an existing client_id in the same realm
invalid_client Public client is not allowed to exchange token Make the requester client confidential
invalid_request Invalid token The subject token is expired, malformed, or from another realm; decode it with the JWT Token Analyzer
invalid_scope Invalid scopes: (the requested scope) Request only optional client scopes that are assigned to the requester client
invalid_scope Missing consents for Token Exchange in client (the client) The user has not granted consent to the requester client for the requested scopes

Retry logic

An expired subject token is the one error that a retry can fix, as long as the service has a way to get a fresh user token first. Retrying any of the other errors only repeats a configuration problem, so let them fail fast.

async function exchangeTokenWithRetry(subjectToken, audience, maxRetries = 1) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await exchangeToken(subjectToken, audience);
    } catch (error) {
      const description = error.response?.data?.error_description || '';
      if (description === 'Invalid token' && attempt < maxRetries) {
        // The subject token may have expired; refresh it before trying again
        subjectToken = await refreshSubjectToken();
        continue;
      }
      throw error;
    }
  }
}

Security best practices

  1. Turn on token exchange only where it is needed. The Standard Token Exchange switch is per client, so leave it off for every client that does not call downstream services on a user’s behalf.
  2. Request one audience. A single audience per exchange keeps each token limited to the service that will receive it.
  3. Allow only downscoping if you want a hard guarantee. By default an exchange can add the requester client’s optional scopes. In Keycloak 26.5 and later, the downscope-assertion-grant-enforcer client policy executor restricts exchanges to narrowing the token.
  4. Keep exchanged tokens short-lived. The target service uses the token immediately, so a lifespan of a few minutes is usually enough.
  5. Protect service-to-service traffic. Exchanges happen between backend services, so authenticate the transport with mTLS as well as the OAuth client credentials. From Keycloak 26.7, standard token exchange accepts a sender-constrained (DPoP or mTLS) subject token only when it was issued to the requesting client and the client presents the matching proof, while 26.6 and earlier reject sender-constrained subject tokens outright. Our write-up of CVE-2026-97846 covers a known gap where a client configured to require certificate-bound tokens is issued a token without that binding.
  6. Audit exchanges. Every exchange records a TOKEN_EXCHANGE event, and an unexpected one can point to a leaked client credential.

Monitoring token exchange

Three numbers tell you most of what you need to know about token exchange in production: the success rate, where a drop usually means a configuration change or an expired client secret; the latency, which should stay well under the latency of the downstream call it enables; and the volume per client, where a sudden spike often means a retry loop.

Keycloak’s event system can stream TOKEN_EXCHANGE events to your SIEM or monitoring platform, and on Skycloak you can follow them through audit logs and Insights.

When to use token exchange and when to skip it

Pattern Use when Avoid when
Token exchange A service needs a scoped, user-specific token for a downstream call Every service already trusts the same token and audience
Token forwarding All services share an issuer and accept the same audience Different services need different permissions
Service account (client credentials) The downstream call is not made on behalf of a user You need to preserve the user’s identity

Token exchange adds a network call and some configuration, so it pays off when you need audience restriction or scope reduction between services. For a small system where all services share one realm and trust the same tokens, forwarding the original token is simpler and is usually enough.


If you would rather not run Keycloak yourself, Skycloak provides managed Keycloak, where standard token exchange is available on any cluster running Keycloak 26.2 or later. See pricing to compare plans.

The pattern above, already wired up

Skycloak gives you a managed Keycloak with OIDC and SAML, social and enterprise identity providers, MFA and fine-grained roles configured and running. Pick your framework during onboarding and you get a working sign-in in about three minutes.

Guilliano Molaire
Written by
Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Start Free Trial Talk to Sales
© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman