Sitemap

Securing OAuth 2.0 Token Exchange Flow with Keycloak

4 min readAug 16, 2024

--

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 scope claim (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.

Press enter or click to view image in full size

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.

Press enter or click to view image in full size

Step 3. Add the newly created token-exchange scope to the source client id list of scopes as Optional.

Press enter or click to view image in full size

Step 4. In the realm-management client, create a new policy for Client Scope.

Press enter or click to view image in full size

And configure it to requre a token-exchange scope.

Press enter or click to view image in full size

Step 5. In the realm-management client, create a new policy for Client id. And configure it to require source client id.

Press enter or click to view image in full size

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.

Press enter or click to view image in full size

And configure it with te-client-policy and te-scope-policy like so:

Press enter or click to view image in full size

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.

--

--