Securing OAuth 2.0 Token Exchange Flow with Keycloak
RFC 8693: Token Exchange describes a mechanism for exchanging an existing token (JWT) for a new token with different issuing client id, subject or audience. The security section suggests the following hardening of token exchange flows:
In addition, both delegation and impersonation introduce unique security issues. Any time one principal is delegated the rights of another principal, the potential for abuse is a concern. The use of the
scopeclaim (in addition to other typical constraints such as a limited token lifetime) is suggested to mitigate potential for such abuse, as it restricts the contexts in which the delegated rights can be exercised.
Let’s take a look at how Keycloak allows to secure the token exchange flow using a client scope claim.
Using Client Id and Scope
The most secure approach is to require both: a specific client id as well as a custom scope claim to be present in the subject token in order to exchange it.
This approach has an important security benefit: if your client id already has existing sessions, then only newly issued tokens with the right scope are capable of going through the token exchange. All previously issued tokens lack the new scope; thus, are precluded from performing a token exchange.
Let’s dive right into Keycloak and see how this can be achieved.
Step 1. Create a new client id, e.g. source.
Step 2. Create a new Client scope.
Below we will configure a new token-exchange scope, and in later steps ensure it is checked by the authorization endpoint.
Step 3. Add the newly created token-exchange scope to the source client id list of scopes as Optional.
Step 4. In the realm-management client, create a new policy for Client Scope.
And configure it to requre a token-exchange scope.
Step 5. In the realm-management client, create a new policy for Client id. And configure it to require source client id.
Step 6. Configure the destination client, to accept token exchange from the source client id. In the Permissions tab of the target client, create a new token-exhcange permission.
And configure it with te-client-policy and te-scope-policy like so:
Et voilà, you’re all set.
Let’s Test It
Here’s a request to obtain a token with atoken-exchange scope:
curl --location 'https://staging/realms/master/protocol/openid-connect/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'client_id=source' \
--data-urlencode 'client_secret=[REDACTED]' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'scope=token-exchange'{
"exp": 1705960843,
"iat": 1705960543,
"jti": "dce22985-5045-40d6-bd6a-cb441d4ef8f6",
"iss": "https://staging/realms/master",
"sub": "7ab49402-3c78-4886-a7b7-d9b60ce77435",
"typ": "Bearer",
"azp": "source",
"aud": "account",
"acr": "default",
"scope": "token-exchange email profile",
"clientHost": "X.X.X.X",
"email_verified": false,
"preferred_username": "service-account-source",
"clientAddress": "X.X.X.X",
"client_id": "source"
}As you can see the source client obtained a token for itself, but notably with the token-exchange scope.
And now let’s exchange for a new token with target audience:
curl --location 'https://staging/realms/master/protocol/openid-connect/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'client_id=source' \
--data-urlencode 'client_secret=[REDACTED]' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode 'subject_token=<token generated for source client_id>' \
--data-urlencode 'requested_token_type=urn:ietf:params:oauth:token-type:access_token' \
--data-urlencode 'audience=target' {
"exp": 1723831073,
"iat": 1723830773,
"jti": "0f460369-3798-4a2e-9d4c-1648408258f6",
"iss": "https://staging/realms/master",
"aud": [
"account",
"target"
],
"sub": "7ab49402-3c78-4886-a7b7-d9b60ce77435",
"typ": "Bearer",
"azp": "source",
"sid": "7cd18bb6-bcc1-460c-b7ec-52ba4bc0fa1f",
"acr": "default",
"scope": "email profile",
"email_verified": false,
"preferred_username": "service-account-source"
}As expected the aud claim now contains the target client id.
But most importantly: if the source client id access token did not have a token-exchange scope, then the token-exchange request would be denied:
{
"error": "access_denied",
"error_description": "Client not allowed to exchange"
}Conclusion
Keycloak allows securing the token-exchange by requiring both a correct client and client scope to be present in the subject access token. This is the recommended and secure approach per RFC.
P.S. Client scope enforcement for token exchange is fully functional as part of Keycloak v26.2.
