Token Exchange at the API Gateway: RFC 8693 in Practice

At a company I worked for in the past, our APIs sat behind an API gateway and the identity provider was Keycloak, and the question that eventually led us to OAuth 2.0 Token Exchange was a simple one: how does the API behind the gateway know who is really calling? The gateway could forward the original consumer's identity to the backend in a header, and on paper that answered it neatly. We looked at it, discussed it, and decided to use that header for auditing only. We did not find it secure enough for authentication, and we did not trust it as proof of who the original client was. A header holds whatever the last sender put in it.
The setup our gateway supported out of the box had two separate legs. The consumer authenticated against the gateway with its preferred method, and the gateway authenticated against the producer with whatever method was configured for that backend. In our case that second leg used client credentials, so the producer only ever saw the gateway. Because we had no secure way to prove the original consumer to the producer, we exposed our Keycloak token endpoint through the gateway. It was a workaround, and it left the question open. The real problem sits in the hop between gateway and producer. The consumer authenticates against the gateway, the gateway authenticates against the producer, and the producer still needs the original consumer to apply its role-based access control. Somebody has to carry the user's identity across that gap in a form the producer can verify.
Token exchange, defined in RFC 8693, was built for exactly that hop, and it was the target design we proposed there. At the time, it was still being debated. In this article I'll show why the three common answers fail, run a full exchange on your laptop with Keycloak and a small .NET API, and show what the producer has to check once the new token arrives: aud, sub, roles, and, once delegation is available, act.
The Hotel Key Card
Think of a hotel. When you check in, you get a key card for the front door and the lobby. That card proves you are a guest, and it should open nothing else. When you want room 412, the front desk clerk takes your lobby card and swaps it for a new one that opens room 412 only. The clerk cannot make that card by hand. It comes out of the card encoder behind the desk, which only the hotel's system can drive, and it still has your name on it. The new card never opens more doors than the old one did. The room lock then checks three things: is this card for this door, whose card is it, and who issued it.
Seen this way, the bad options are easy to spot. Passthrough is a lobby card that also opens every room door. The forwarded header is the clerk walking up to room 412 with no card and telling the lock, "trust me, this is the guest from 412." Locks are famously bad at trust.
The analogy has limits. The card answers one question: which door. What you may do once inside is a separate question, and in an API that is the producer's job. Roles work more like room service reading the guest's profile than like the lock. And in OAuth the swap happens on every call, unseen by the user, where a hotel guest at least remembers visiting the front desk.
Token Exchange in One Request
RFC 8693 adds one grant type to the token endpoint: urn:ietf:params:oauth:grant-type:token-exchange. The gateway sends the consumer's token as subject_token together with its subject_token_type, both required. It can add its own token as actor_token, and actor_token_type is only sent with it. The rest, resource, audience, scope and requested_token_type, is optional and lets the gateway say which door it wants the new card for.
Section 1.1 of the RFC separates two modes. With impersonation, the new token simply says it is the user, and the producer cannot tell anyone else was involved. With delegation, the token says it is the user and records the party acting for them in an act claim (section 4.1). Section 4.4 adds may_act, a claim in the subject token naming who is allowed to act for the subject. Delegation is the notary's letter, signed by the representative on behalf of the person, with both names on the page.
I prefer delegation. A producer that sees both the user and the gateway can make a better decision, and leave a better audit trail, than one that sees only the user. "Alice, through the gateway" is a much shorter investigation than "Alice, somehow."
Where the Vendors Are
Support is uneven, so check before you design around it. Keycloak has fully supported standard token exchange (V2) since 26.2.0 in April 2025, for clients in the same realm. It accepts only access tokens as subject_token, audience can only narrow the token, and the requesting client must be confidential and listed in the subject token's aud. Keycloak's documentation lists impersonation as not implemented yet in V2. Delegation with act arrived as a preview in Keycloak 26.8.0 in October 2026, and it needs the token-exchange-delegation and parameterized-scopes features, a fine-grained admin delegate permission, and consent on the requesting client. That is a lot of preview switches, so the walkthrough below stays on V2.
Elsewhere, Microsoft Entra ID does not implement RFC 8693. Its on-behalf-of flow uses the jwt-bearer grant with requested_token_use=on_behalf_of. Auth0 offers On-Behalf-Of token exchange based on RFC 8693, with the chain recorded in act. On the gateway side, Kong's OpenID Connect plugin supports token exchange from Gateway 3.14 and actor tokens from 3.16.
Three Answers That Break at the Second Hop
Passthrough. The gateway forwards the consumer's token unchanged. It looks simple until you look at the producer's validation. To accept that token, the producer must accept a token whose audience is someone else, namely the gateway. Once a producer accepts tokens meant for another service, its audience check is decoration.
Forwarded identity header. The gateway authenticates the consumer and adds something like X-User-Id: alice to the onward call. That header is only an assertion. Anyone who can reach the producer, by any route, can send any identity they like. Network rules help, but then your security depends on nobody ever finding another path to the producer. For an audit log, where you want a record of who was involved, that is fine. For an access decision it is not enough.
Client credentials only. The gateway calls the producer with its own token. This is where that company started, because client credentials was the method configured for the leg between the gateway and the backend. The producer gets a valid token with the right audience and loses the user completely. Every request looks like the gateway, so every request gets the gateway's permissions, and per-user RBAC is gone. The gateway quietly becomes the most powerful user in the building.
Token exchange closes all three gaps. The producer receives a token signed by the identity provider, issued for itself, and carrying the original user.
Keycloak Token Exchange Walkthrough on Your Laptop
You need Docker, the .NET 10 SDK, curl, jq and a bash shell. Everything runs on localhost: Keycloak on port 8080, the producer on port 5000. curl already comes with Windows 10 and later, macOS and most Linux distributions. Install anything else that is missing with your package manager:
# Windows, from PowerShell. Git for Windows gives you Git Bash for the steps below.
winget install --id Docker.DockerDesktop --exact
winget install --id Microsoft.DotNet.SDK.10 --exact
winget install --id jqlang.jq --exact
winget install --id Git.Git --exact
# macOS, with Homebrew
brew install --cask docker-desktop dotnet-sdk
brew install jq
# Ubuntu 26.04
sudo apt install docker.io dotnet-sdk-10.0 jq curl
sudo usermod -aG docker $USER # run docker without sudo; log out and in again
# Then open a new terminal, start Docker Desktop (Windows, macOS) and check:
docker --version && dotnet --version && jq --version
Step 1: Describe the Hotel in a Realm File
Create a working folder with a realm subfolder, and stay in the working folder for the rest of the walkthrough:
mkdir -p TokenExchangeDemo/realm
cd TokenExchangeDemo
Save the realm below as realm/demo-realm.json. It holds three clients. consumer-app is a public client using the password grant to keep the demo short; real consumers use the authorization code flow. It has fullScopeAllowed off, and an audience mapper puts api-gateway in the token's aud, because Keycloak only lets a client exchange a token that names it in aud. api-gateway is confidential with standard token exchange switched on. orders-api is the producer and owns two client roles. Alice gets order-viewer, Bob gets only order-editor.
{
"realm": "demo",
"enabled": true,
"clients": [
{
"clientId": "consumer-app",
"publicClient": true,
"directAccessGrantsEnabled": true,
"standardFlowEnabled": false,
"fullScopeAllowed": false,
"protocolMappers": [
{
"name": "gateway-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": {
"included.client.audience": "api-gateway",
"access.token.claim": "true"
}
}
]
},
{
"clientId": "api-gateway",
"publicClient": false,
"secret": "gateway-secret",
"standardFlowEnabled": false,
"attributes": {
"standard.token.exchange.enabled": "true"
}
},
{
"clientId": "orders-api",
"publicClient": false,
"secret": "orders-secret",
"standardFlowEnabled": false
}
],
"roles": {
"client": {
"orders-api": [
{ "name": "order-viewer" },
{ "name": "order-editor" }
]
}
},
"users": [
{
"username": "alice",
"enabled": true,
"email": "alice@example.com",
"emailVerified": true,
"firstName": "Alice",
"lastName": "Example",
"credentials": [{ "type": "password", "value": "alice", "temporary": false }],
"clientRoles": { "orders-api": ["order-viewer"] }
},
{
"username": "bob",
"enabled": true,
"email": "bob@example.com",
"emailVerified": true,
"firstName": "Bob",
"lastName": "Example",
"credentials": [{ "type": "password", "value": "bob", "temporary": false }],
"clientRoles": { "orders-api": ["order-editor"] }
}
]
}
Step 2: Start Keycloak
Run this from the folder that contains realm/. Keycloak imports the realm on startup, which takes 10 to 30 seconds depending on your machine, so check the logs before moving on.
docker run -d --name kc-exchange -p 8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
-v "$(pwd)/realm:/opt/keycloak/data/import" \
quay.io/keycloak/keycloak:26.8.1 start-dev --import-realm
docker logs kc-exchange 2>&1 | grep -E "imported|started"
# Expected, once ready:
# ... Realm 'demo' imported
# ... Keycloak 26.8.1 on JVM (powered by Quarkus 3.40.1) started in 8.200s. Listening on: http://0.0.0.0:8080
If nothing shows yet, wait a few seconds and run the docker logs command again. If you see the started line but not imported, the realm folder was not mounted. On Windows in Git Bash this happens because Git Bash rewrites the container path. Remove the container with docker rm -f kc-exchange and run the same command again with MSYS_NO_PATHCONV=1 in front of docker run.
Step 3: Check In as the Consumer
This is the lobby card. The small jwt helper decodes the token payload so you can read the claims.
TOKEN_URL=http://localhost:8080/realms/demo/protocol/openid-connect/token
USER_TOKEN=$(curl -s $TOKEN_URL \
-d grant_type=password -d client_id=consumer-app \
-d username=alice -d password=alice | jq -r .access_token)
jwt() { jq -R 'split(".")[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson'; }
echo "$USER_TOKEN" | jwt | jq '{aud, azp, preferred_username, resource_access}'
{
"aud": "api-gateway",
"azp": "consumer-app",
"preferred_username": "alice",
"resource_access": null
}
The audience is the gateway and there are no producer roles at all. This card was never meant for the orders door.
Step 4: Let the Front Desk Swap the Card
Now the gateway acts as the front desk. It authenticates with its own secret, hands over Alice's token as subject_token, and asks for a token for orders-api.
EXCHANGED=$(curl -s $TOKEN_URL -u api-gateway:gateway-secret \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token="$USER_TOKEN" \
-d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
-d audience=orders-api | jq -r .access_token)
echo "$EXCHANGED" | jwt | jq '{aud, azp, sub, preferred_username, resource_access}'
{
"aud": "orders-api",
"azp": "api-gateway",
"sub": "11406394-eb0f-4e62-a672-2a88e9ccc0b5",
"preferred_username": "alice",
"resource_access": {
"orders-api": {
"roles": [
"order-viewer"
]
}
}
}
Your sub will be a different UUID, but it matches Alice's original token. The aud is new, the roles cover the producer only, and azp names the gateway. There is no act claim, because delegation is still a preview. The full response also reports expires_in: 300 and an issued_token_type of urn:ietf:params:oauth:token-type:access_token.
The encoder refuses bad requests too. Ask for audience=billing-api and you get {"error":"invalid_client","error_description":"Audience not found"}. Exchange the token of a user with no roles at all on orders-api and you get Requested audience not available: orders-api. The front desk will not encode a card for a room the guest has no booking for.
Step 5: Build the Room Lock in .NET
The producer validates the card. Authority gives it the issuer and signing keys, ValidAudience rejects any token not meant for orders-api, and MapInboundClaims = false keeps Keycloak's claim names unchanged. The policy then reads client roles from resource_access. Once delegation leaves preview, this is also where you would read act and decide which actors you accept.
dotnet new web -n OrdersApi
cd OrdersApi
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 10.0.0
Replace the contents of Program.cs with this:
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication.JwtBearer;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "http://localhost:8080/realms/demo";
options.RequireHttpsMetadata = false; // localhost only
options.TokenValidationParameters.ValidAudience = "orders-api";
options.MapInboundClaims = false;
});
builder.Services.AddAuthorization(options =>
options.AddPolicy("OrderViewer", policy =>
policy.RequireAssertion(ctx => HasClientRole(ctx.User, "orders-api", "order-viewer"))));
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/orders", (ClaimsPrincipal user) => new
{
user = user.FindFirstValue("preferred_username"),
calledVia = user.FindFirstValue("azp"),
orders = new[] { "ORD-1001", "ORD-1002" }
}).RequireAuthorization("OrderViewer");
app.Run("http://localhost:5000");
// Keycloak puts client roles in resource_access.<client>.roles
static bool HasClientRole(ClaimsPrincipal user, string client, string role)
{
var json = user.FindFirstValue("resource_access");
if (json is null) return false;
using var doc = JsonDocument.Parse(json);
return doc.RootElement.TryGetProperty(client, out var c)
&& c.TryGetProperty("roles", out var roles)
&& roles.EnumerateArray().Any(r => r.GetString() == role);
}
Start it with dotnet run. Then open a second terminal and repeat the commands from Steps 3 and 4, so TOKEN_URL, USER_TOKEN, EXCHANGED and the jwt helper are set there.
Step 6: Knock on the Door, Then Clean Up
Try all three approaches against the producer, then repeat the exchange for Bob.
# Passthrough: the consumer's own token
curl -i http://localhost:5000/orders -H "Authorization: Bearer $USER_TOKEN"
# HTTP/1.1 401 Unauthorized
# WWW-Authenticate: Bearer error="invalid_token", error_description="The audience 'api-gateway' is invalid"
# Forwarded header, no token
curl -i http://localhost:5000/orders -H "X-User-Id: alice"
# HTTP/1.1 401 Unauthorized
# Exchanged token
curl -i http://localhost:5000/orders -H "Authorization: Bearer $EXCHANGED"
# HTTP/1.1 200 OK
# {"user":"alice","calledVia":"api-gateway","orders":["ORD-1001","ORD-1002"]}
# Bob: same login and exchange, but he only has order-editor
BOB_TOKEN=$(curl -s $TOKEN_URL \
-d grant_type=password -d client_id=consumer-app \
-d username=bob -d password=bob | jq -r .access_token)
BOB_EXCHANGED=$(curl -s $TOKEN_URL -u api-gateway:gateway-secret \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token="$BOB_TOKEN" \
-d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
-d audience=orders-api | jq -r .access_token)
curl -i http://localhost:5000/orders -H "Authorization: Bearer $BOB_EXCHANGED"
# HTTP/1.1 403 Forbidden
# Teardown (and Ctrl+C the .NET app in the first terminal)
docker rm -f kc-exchange
The passthrough token fails on audience, the header fails because there is no token at all, and Alice's exchanged token gets her orders. Bob's 403 is the interesting one. His card opens the right door, his token is valid, and the producer still says no, because Bob has no order-viewer role. That is the producer applying RBAC to the original user, which was the point of the whole exercise. Right room, but room service read the profile.
Token Exchange vs Passthrough, Headers and Client Credentials
| Approach | Producer sees the user | What the producer can verify | Per-user RBAC | Producer's audience check |
|---|---|---|---|---|
| Passthrough | Yes | IdP-signed token, meant for the gateway | Yes, if roles are in the token | Must be loosened |
| Forwarded header | Only as a claim | Nothing | Only if you trust the header | No token to check |
| Client credentials only | No | IdP-signed token for the gateway itself | No | Passes |
Exchange, no act (Keycloak V2 today) |
Yes | IdP-signed token issued for the producer | Yes | Passes |
Exchange with delegation (act) |
Yes, plus the gateway | Same, with the actor recorded | Yes | Passes |
The last two rows have a cost the first three avoid. Every exchange is an extra call to the identity provider, and in this setup the exchanged token lives 300 seconds.
Every Door Checks Its Own Card
- Audience is the door. A producer accepts only tokens with its own name in
aud. - The subject travels, the audience changes. Exchange keeps
suband narrowsaud, it never widens it. - Headers belong in the logs. A forwarded identity is useful for audit and worthless as proof.
- Roles are the producer's call. The token carries them, the producer decides what they allow.
- Prefer delegation where your IdP supports it. An
actclaim records who acted for whom.
That is where I stand. I prefer delegation. At that company, token exchange was the target design we proposed, and it was still under debate at the time. The forwarded header stayed where we had put it, in the audit log.
What is your producer actually checking when a request arrives from the gateway? If a caller reached it without passing the front desk, would it notice? And most importantly, could it tell you which guest it just let in?
Share this article
Related articles

Post-Quantum Cryptography: The TLS Inventory to Run Now
Post-quantum cryptography migration can't wait for quantum computers. Run this 20-minute OpenSSL check on your own TLS endpoints today.

Sovereign Cloud Strategy: Residency Is Not Enough
Sovereign cloud vendors sell EU regions and sovereign SKUs, but the US CLOUD Act reaches data by possession, not location. Decide the real threat first.

AI Gateway: Cost Control, Failover, and New Risk
An AI gateway centralizes routing, budgets, and credentials for LLM traffic, but it also becomes a critical-path dependency with its own outage risk.
Enjoyed this article?
Subscribe to get more insights delivered to your inbox monthly
No spam, unsubscribe anytime.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.