For the complete documentation index, see llms.txt. Markdown versions of all docs pages are available by appending .md to any docs URL.
Keycloak
Keep user context in requests with Keycloak standard token exchange.
Exchange the user’s Keycloak access token that a client sends to the gateway for a new access token at Keycloak, so that the backend receives a token that is issued for its own audience.
About
Keycloak implements the RFC 8693 token exchange grant as standard token exchange, which is supported in Keycloak 26.2 and later and is turned on per client. The gateway is a confidential client of the realm. It sends the user’s access token as the subject_token, and Keycloak returns a new access token for the same user (sub), issued for the backend’s audience, with the gateway’s client as the authorized party (azp).
sequenceDiagram
participant Client as Client (agent)
participant AGW as agentgateway
participant KC as Keycloak
participant API as Backend
Client->>AGW: Request + user's access token<br/>aud includes agentgateway
AGW->>KC: POST /realms/REALM/protocol/openid-connect/token<br/>subject_token=user's token, audience=backend<br/>client ID and secret of agentgateway
KC-->>AGW: Access token<br/>sub=user, aud=backend, azp=agentgateway
AGW->>API: Request + Authorization: Bearer exchanged token
API-->>AGW: Response
AGW-->>Client: Response
The guide uses three Keycloak clients.
agentis the caller. It signs in the user and gets the user’s token, called the incoming token. In the test realm, you play this client yourself and usecurlto get the token.agentgatewayis the gateway. It sends the incoming token to Keycloak and gets the exchanged token back.backendrepresents the backend API. Keycloak issues the exchanged token for this audience, and the gateway sends the token to the backend.
Keycloak checks two audiences during the exchange.
- The incoming token must list
agentgatewayin itsaudclaim. agentgatewaymust be allowed to getbackendin its own tokens.
The standard token exchange guide runs this exchange end to end against a sample Keycloak realm that comes preconfigured. This guide shows the realm settings that make the exchange work, so that you can set up your own realm.
Keycloak settings
Keycloak uses the standard RFC 8693 grant, so you configure it with the generic oauthTokenExchange method. The following settings are specific to Keycloak.
| Field | Value for Keycloak |
|---|---|
url | KEYCLOAK_URL/realms/REALM/protocol/openid-connect/token. Keycloak checks the iss claim of the incoming token against this URL, so the gateway must reach Keycloak through the same hostname that issued the token. |
grantType | TokenExchange, the default. |
subjectToken.tokenType | AccessToken, the default. Keycloak accepts only access tokens as the subject token. |
requestedTokenType | Omit, or set AccessToken. Keycloak also issues an IdToken, but rejects Jwt. |
audiences | The client IDs of the backends that the exchanged token is for, such as backend. Each audience must be available to the gateway’s client. If you omit this field, the exchanged token has every audience that is available to the gateway’s client. |
scopes | Optional. Default or optional client scopes of the gateway’s client. |
clientAuth | Required. The client ID and client secret of the gateway’s confidential client. Set method to ClientSecretBasic, the default, or ClientSecretPost. |
Before you begin
Follow the Get started guide to install agentgateway.
Follow the Sample app guide to create a gateway proxy with an HTTP listener and deploy the httpbin sample app.
Get the external address of the gateway and save it in an environment variable.
Tip
Kind cluster? Kind does not support
LoadBalancerservices by default. To use this option with a Kind cluster, install and runcloud-provider-kind.export INGRESS_GW_ADDRESS=$(kubectl get svc -n agentgateway-system agentgateway-proxy -o jsonpath="{.status.loadBalancer.ingress[0]['hostname','ip']}") echo $INGRESS_GW_ADDRESS
Get admin access to a Keycloak server, version 26.2 or later. The gateway and your machine must reach Keycloak at the same URL, because Keycloak rejects an incoming token with an
issclaim that does not match the URL that the gateway calls. If you run Keycloak in the cluster, set the issuer with theKC_HOSTNAMEenvironment variable. For an example, see the standard token exchange guide.Install
jq.Download the Keycloak distribution and extract its
bindirectory, which has thekcadm.shadmin CLI. Then, add the directory to yourPATH. The admin CLI requires Java 17 or later.export KEYCLOAK_VERSION=26.7.1 curl -sL https://github.com/keycloak/keycloak/releases/download/$KEYCLOAK_VERSION/keycloak-$KEYCLOAK_VERSION.tar.gz \ | tar xz keycloak-$KEYCLOAK_VERSION/bin export PATH="$PWD/keycloak-$KEYCLOAK_VERSION/bin:$PATH"
Step 1: Configure the realm
Standard token exchange needs a few settings in the realm: a confidential client for the gateway with token exchange turned on, the gateway in the audience of the user’s token, the backend’s audience available to the gateway, and a client scope to down-scope the exchanged token. The following steps use the kcadm.sh admin CLI, which is in the bin directory of the Keycloak distribution. Each step also names the matching setting in the Admin Console.
Save the URL of your Keycloak server and the name of your realm in environment variables, and log in to the admin CLI.
export KEYCLOAK_URL=http://localhost:7080 export REALM=agentgateway kcadm.sh config credentials --server $KEYCLOAK_URL --realm master --user adminWhen prompted, enter the admin password, such as
admin.Optional: If you do not have a realm to try the exchange in, create a test realm, and set up the required clients and user. The realm has a client that issues user tokens,
agent, with direct access grants so that you can get a token withcurl, a client that represents the backend API,backend, and a user,testuser. If you use your own realm, replaceagent,backend, and the user in the following steps.kcadm.sh create realms -s realm=$REALM -s enabled=true kcadm.sh create clients -r $REALM -s clientId=agent -s secret=agent-secret \ -s publicClient=false -s standardFlowEnabled=false -s directAccessGrantsEnabled=true kcadm.sh create clients -r $REALM -s clientId=backend -s publicClient=false -s standardFlowEnabled=false kcadm.sh create users -r $REALM -s username=testuser -s enabled=true \ -s email=[email protected] -s emailVerified=true -s firstName=Test -s lastName=User kcadm.sh set-password -r $REALM --username testuser --new-password testpassCreate a confidential client for the gateway,
agentgateway, with standard token exchange turned on. In the Admin Console, turn on Client authentication and select Standard Token Exchange in the Capability config of the client. The gateway does not sign in users, so the standard flow is off.kcadm.sh create clients -r $REALM -s clientId=agentgateway \ -s publicClient=false -s standardFlowEnabled=false \ -s 'attributes."standard.token.exchange.enabled"=true' export GATEWAY_ID=$(kcadm.sh get clients -r $REALM -q clientId=agentgateway --fields id --format csv --noquotes) export GATEWAY_CLIENT_SECRET=$(kcadm.sh get clients/$GATEWAY_ID/client-secret -r $REALM --fields value --format csv --noquotes)Make the user’s token name the gateway as a recipient. Keycloak exchanges a token only if the client that asks for the exchange,
agentgateway, is in the token’saudclaim. Create a client scope with an audience mapper foragentgateway, and make it a default client scope of theagentclient, which issues the user’s token. In the Admin Console, the mapper is an Audience mapper with Included Client Audience set toagentgateway. Tokens fromagentnow includeagentgatewayinaud.SCOPE_ID=$(kcadm.sh create client-scopes -r $REALM -s name=agentgateway-audience \ -s protocol=openid-connect -s 'attributes."include.in.token.scope"=false' -i) kcadm.sh create client-scopes/$SCOPE_ID/protocol-mappers/models -r $REALM \ -s name=agentgateway-audience -s protocol=openid-connect -s protocolMapper=oidc-audience-mapper \ -s 'config."included.client.audience"=agentgateway' -s 'config."access.token.claim"=true' AGENT_ID=$(kcadm.sh get clients -r $REALM -q clientId=agent --fields id --format csv --noquotes) kcadm.sh update clients/$AGENT_ID/default-client-scopes/$SCOPE_ID -r $REALMAllow the gateway to request a token for the backend. Keycloak issues the exchanged token only for an audience that the gateway’s client can get in its own tokens. Create a client scope with an audience mapper for
backend, and make it a default client scope of theagentgatewayclient. This step is the counterpart to step 4, which covers the incoming token.SCOPE_ID=$(kcadm.sh create client-scopes -r $REALM -s name=backend-audience \ -s protocol=openid-connect -s 'attributes."include.in.token.scope"=false' -i) kcadm.sh create client-scopes/$SCOPE_ID/protocol-mappers/models -r $REALM \ -s name=backend-audience -s protocol=openid-connect -s protocolMapper=oidc-audience-mapper \ -s 'config."included.client.audience"=backend' -s 'config."access.token.claim"=true' kcadm.sh update clients/$GATEWAY_ID/default-client-scopes/$SCOPE_ID -r $REALMCreate a client scope that the gateway can request in the exchange, such as
backend.read, and make it an optional client scope of theagentgatewayclient. When the gateway sends the scope, Keycloak adds it to thescopeclaim of the exchanged token. The scope limits what the exchanged token allows, so the backend can check thescopeclaim and grant the request only read access. The gateway configuration in this guide requestsbackend.read.SCOPE_ID=$(kcadm.sh create client-scopes -r $REALM -s name=backend.read -s protocol=openid-connect -i) kcadm.sh update clients/$GATEWAY_ID/optional-client-scopes/$SCOPE_ID -r $REALMGet an access token for the test user from the
agentclient, and save it in an environment variable. You use this token as the incoming token that a real client would send to the gateway. Keycloak access tokens expire after 5 minutes by default, so get a new one if you come back to this guide later.export INBOUND_TOKEN=$(curl -s $KEYCLOAK_URL/realms/$REALM/protocol/openid-connect/token \ -u agent:agent-secret -d grant_type=password \ -d username=testuser -d password=testpass | jq -r .access_token)Exchange the token at Keycloak as the gateway, and decode the result. This request is the same one that agentgateway sends, so it confirms the realm settings before you configure the gateway.
curl -s $KEYCLOAK_URL/realms/$REALM/protocol/openid-connect/token \ -u agentgateway:$GATEWAY_CLIENT_SECRET \ -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \ -d subject_token_type=urn:ietf:params:oauth:token-type:access_token \ -d subject_token=$INBOUND_TOKEN \ -d audience=backend -d scope=backend.read \ | jq -r .access_token | cut -d. -f2 \ | jq -R 'gsub("-";"+") | gsub("_";"/") | . + ("=" * ((4 - (length % 4)) % 4)) | @base64d | fromjson | {aud, azp, sub, scope}'Example output:
{ "aud": "backend", "azp": "agentgateway", "sub": "d7386852-9a23-44b2-b8ea-a2d81f73a014", "scope": "backend.read profile email" }The output confirms that the realm is ready for the gateway. The
audclaim isbackend, theazpclaim isagentgateway, and thesubclaim is the same user as in the incoming token. Thescopeclaim includesbackend.read, plus the default client scopes of theagentgatewayclient. If the exchange fails, Keycloak returns an error instead, such asClient is not within the token audience. For more errors, see Troubleshooting.
Step 2: Configure token exchange
Create a Kubernetes Secret with the client secret of the
agentgatewayclient. The policy reads the secret from theclientSecretkey.kubectl create secret generic keycloak-client -n httpbin \ --from-literal=clientSecret=$GATEWAY_CLIENT_SECRETCreate an AgentgatewayPolicy that exchanges the incoming token at Keycloak for every request to the httpbin app. The shell fills in your Keycloak URL and realm.
kubectl apply -f- <<EOF apiVersion: agentgateway.dev/v1alpha1 kind: AgentgatewayPolicy metadata: name: keycloak-token-exchange namespace: httpbin spec: targetRefs: - group: "" kind: Service name: httpbin backend: auth: oauthTokenExchange: url: $KEYCLOAK_URL/realms/$REALM/protocol/openid-connect/token grantType: TokenExchange audiences: - backend scopes: - backend.read clientAuth: clientId: agentgateway method: ClientSecretBasic secretRef: name: keycloak-client EOFFor a description of each setting, see Keycloak settings.
Confirm that the policy is accepted and attached.
kubectl get AgentgatewayPolicy keycloak-token-exchange -n httpbin \ -o jsonpath='{range .status.ancestors[0].conditions[*]}{.type}={.status}{"\n"}{end}'Example output:
Accepted=True Attached=True
Step 3: Verify the exchange
Send a request to the httpbin /headers endpoint through the gateway, with the user’s token, and decode the token that httpbin received. If more than 5 minutes passed since you saved the token in the $INBOUND_TOKEN environment variable, get a new token by repeating step 7 of the realm setup.
curl -s http://$INGRESS_GW_ADDRESS/headers \
-H "host: www.example.com" \
-H "authorization: Bearer $INBOUND_TOKEN" \
| jq -r '.headers.Authorization[0] | sub("^Bearer ";"")' \
| cut -d. -f2 \
| jq -R 'gsub("-";"+") | gsub("_";"/") | . + ("=" * ((4 - (length % 4)) % 4)) | @base64d | fromjson | {aud, azp, sub, scope}'The backend received the exchanged token, for the backend audience with agentgateway as the authorized party, not the token that you sent.
{
"aud": "backend",
"azp": "agentgateway",
"sub": "d7386852-9a23-44b2-b8ea-a2d81f73a014",
"scope": "backend.read profile email"
}Next steps
The exchange does not validate the incoming token itself; Keycloak does. To reject an invalid or expired token at the gateway before it calls Keycloak, add a JWT authentication policy, as described in Validate the incoming token at the edge. Set preserveToken: true on that policy, so that the token is still in the request when the exchange runs.
Troubleshooting
When Keycloak rejects the exchange, the gateway returns 400 with the body invalid request. The response from Keycloak is logged at debug level. To see it, raise the log level of the token exchange module on the proxy, send the request again, and check the proxy logs.
kubectl port-forward deployment/agentgateway-proxy -n agentgateway-system 15000 &
curl -X POST "http://localhost:15000/logging?level=info,agentgateway::http::auth::oauth=debug"
kubectl logs deployment/agentgateway-proxy -n agentgateway-system | grep "oauth token exchange"The change lasts until the proxy restarts. Look for a message such as oauth token exchange rejected by authorization server or oauth token exchange rejected gateway credentials, which includes the error from Keycloak.
| Error from Keycloak | Cause |
|---|---|
access_denied: Client is not within the token audience | The aud claim of the incoming token does not include the gateway’s client. Add an audience mapper for agentgateway to the client that issues the token. Keycloak returns 403, so the gateway returns 500 with backend authentication failed: token exchange returned status 403 Forbidden. |
invalid_request: Standard token exchange is not enabled for the requested client | The client in clientAuth does not have Standard Token Exchange turned on, or clientAuth names a different client than you configured. |
invalid_request: Requested audience not available: <client> | The gateway’s client cannot get the requested audience in its own tokens. Add an audience mapper for the backend client to a default client scope of the gateway’s client. |
invalid_client: Audience not found | A value in audiences is not a client ID in the realm. |
invalid_scope: Invalid scopes: <scope> | A value in scopes is not a default or optional client scope of the gateway’s client. |
invalid_request: Invalid token | Keycloak rejected the incoming token: it expired, comes from another realm, or its iss claim does not match the issuer of the token endpoint as the gateway reaches it. The Keycloak log shows subject_token validation failure. |
invalid_request: Parameter ‘subject_token’ supports access tokens only | subjectToken.tokenType is set to a JWT or ID token type. Remove the field to send the default, the access token type. |
invalid_request: requested_token_type unsupported | requestedTokenType is set to the JWT type. Remove the field, or request an access token or an ID token. |
unauthorized_client: Invalid client or Invalid client credentials | The client secret does not match the gateway’s client. Keycloak returns 401, so the gateway returns 500 with backend authentication failed. |
Cleanup
You can remove the resources that you created in this guide.Delete the Kubernetes resources.
kubectl delete AgentgatewayPolicy keycloak-token-exchange -n httpbin kubectl delete secret keycloak-client -n httpbinIf you ran the admin port-forward in the troubleshooting section, stop it.
pkill -f "port-forward deployment/agentgateway-proxy" || trueDelete the Keycloak resources. If you created the test realm, delete the realm. Otherwise, delete the
agentgatewayclient and the client scopes that you created.kcadm.sh delete realms/$REALMOptional: Remove the Keycloak distribution that you downloaded.
rm -rf keycloak-$KEYCLOAK_VERSION