
Runnable Spring Boot and Quarkus reproducer for CVE-2026-94386, demonstrating cross-scope OAuth token reuse in Apache Camel camel-oauth's static token cache.
Runnable proof-of-concept reproducer for the Apache Camel vulnerability where
OAuthClientCredentialsTokenResolver kept the client-credentials tokens it obtained in a static cache
shared by every resolver instance and every CamelContext in the JVM, and keyed that cache on the token
endpoint and the client ID only — although the token request also carries the client secret and the
requested scope.
// affected
record TokenCacheKey(String tokenEndpoint, String clientId) { }
| Runtime | Directory | Stack |
|---|---|---|
| Camel Spring Boot | camel-spring-boot/ | Spring Boot 3.5.13 + camel-oauth 4.22.1 |
| Camel Quarkus | camel-quarkus/ | Camel Quarkus 3.39.0 (Quarkus 3.39.0, Camel 4.22.0) |
Two profiles that share a token endpoint and a client ID but request different scopes, or authenticate with different client secrets, mapped to the same cache entry. The profile that resolved first populated it; the other was then handed that token, with no token request made under its own configuration, for as long as the token remained valid.
Token caching is on by default, and pointing several profiles at one identity provider client is an ordinary use of the profile support rather than an opt-in to an insecure setting.
cd camel-spring-boot # or: cd camel-quarkus
mvn clean package
docker compose up -d --build
until curl -sf http://localhost:8080/exploit/ready >/dev/null; do sleep 1; done
curl -s http://localhost:8080/exploit/attack
docker compose down
Output on an affected build (identical on both runtimes):
1) Profile 'invoices' resolves first
token handed to it: TOKEN-FOR[scope=invoice.read;secret=...aaaa]
token requests the IdP has received: 1
2) Profile 'admin' resolves next, through a DIFFERENT resolver instance
token handed to it: TOKEN-FOR[scope=invoice.read;secret=...aaaa]
token requests the IdP has received: 1 (unchanged - no request was made under its own config)
it was handed the token issued for the OTHER profile's scope: true
3) Profile 'partner' - same endpoint and clientId, DIFFERENT client secret (...-zzzz)
token handed to it: TOKEN-FOR[scope=invoice.read;secret=...aaaa]
it was served the token obtained with the other secret, without ever
authenticating with its own: true
4) CONTROL - profile 'admin' again with cache-tokens=false
token handed to it: TOKEN-FOR[scope=admin.write;secret=...aaaa]
it got a token for its own scope: true
Everything the IdP was actually asked for:
- client_id=camel-integration-client scope=invoice.read;secret=...aaaa
- client_id=camel-integration-client scope=admin.write;secret=...aaaa
>>> PROVEN: ...
The token value names the scope and secret it was issued for, so you can see which profile's
configuration each token actually came from. The request log at the end is the hard evidence: with
caching on, the identity provider was never asked for the admin profile's token at all — only the first
profile's request and the control's appear.
Probe 2 uses a second resolver instance deliberately: the cache is static, so a separate instance —
and a separate CamelContext — shares it.
The resolver obtains tokens on behalf of the components that authenticate their outbound calls with a named
OAuth profile — camel-openai, camel-langchain4j-agent, camel-huggingface, camel-docling,
camel-a2a, camel-spring-ai-chat and the IBM watsonx components — and of applications calling the
OAuthClientAuthenticationFactory SPI directly.
Reaching the issue requires a deployment that defines more than one profile sharing a token endpoint and client ID while differing in scope or client secret.
The profiles are configured by the deployment, which is trusted, so no untrusted input reaches the cache. The party that gains is the service that receives a token it was not meant to receive, which, if hostile or compromised, can replay that token within its scope. The outcome is an outbound request carrying a token issued for another profile's scope — broader authority than the calling profile was configured for, or a service the token was not issued for. With identity providers that derive a token's audience from the requested scope, a different scope can mean a token for a different service altogether.
No identity provider and no network. The app hosts a stand-in for the provider's token endpoint serving the
client-credentials grant, which issues a token whose value names the scope and client secret it was issued
for, and records every request. The real OAuthClientCredentialsTokenResolver and the real static cache run.
The stand-in reads the client credentials from the HTTP Basic authorization header, not from form parameters, because that is how
camel-oauthsends them.
| Property | Value |
|---|---|
| Component | camel-oauth (OAuthClientCredentialsTokenResolver) |
| CWE | CWE-524 (Use of Cache Containing Sensitive Information) |
| Severity | Medium |
| Attack vector | Two or more OAuth profiles sharing a token endpoint and client ID while differing in scope or client secret |
| Impact | An outbound request carrying a token issued for another profile's scope or obtained with another profile's secret |
| Affected Versions | From 4.19.0 before 4.22.2 |
| Fixed Versions | 4.22.2, 4.23.0 |
| JIRA | CAMEL-24872 |
| Credit | Andrea Cosentino from Apache Software Foundation |
Advisory: https://camel.apache.org/security/CVE-2026-94386.html
The cache key now holds every input of the token request: the token endpoint, the client ID, a SHA-256
fingerprint of the client secret — so the raw secret is not kept in the static cache — and the requested
scope. An absent, empty or blank scope counts as one value, because each of them leaves the scope
parameter out of the request; any other scope is matched exactly. Profiles that agree on all four still
share a token across resolver instances, while profiles that differ in credentials or scope obtain separate
tokens.
Fixed on main in apache/camel#26681 (5e896b2d), backported
to camel-4.22.x in #27779 (b2d9957b).
Set camel.version to 4.22.2 and run it again. Verified against 4.22.1 → 4.22.2:
2) Profile 'admin' resolves next, through a DIFFERENT resolver instance
token handed to it: TOKEN-FOR[scope=admin.write;secret=...aaaa]
token requests the IdP has received: 2
it was handed the token issued for the OTHER profile's scope: false
3) Profile 'partner' ... token handed to it: TOKEN-FOR[scope=invoice.read;secret=...zzzz]
it was served the token obtained with the other secret ...: false
>>> NOT reproduced: each profile got a token for its own scope and secret.
All four token requests now reach the identity provider, each under its own configuration.
Disable token caching on every OAuth profile that shares a token endpoint and client ID with another
profile — camel.oauth.NAME.cache-tokens=false for a named profile, camel.oauth.cache-tokens=false for
the default one — at the cost of a token request on every resolution (probe 4 shows this working).
Alternatively, register a separate identity provider client for each profile that needs a different scope or
client secret, so that no two such profiles share a client ID.