
Runnable proof-of-concept reproducer for CVE-2026-95663, where Apache Camel camel-keycloak served cached token introspection results past token expiry, with Spring Boot and Quarkus demos.
Runnable proof-of-concept reproducer for the Apache Camel vulnerability where KeycloakSecurityPolicy with
token introspection enabled (useTokenIntrospection=true) kept honouring an access token after it had
expired, because the cached introspection result outlived the token.
| Runtime | Directory | Stack |
|---|---|---|
| Camel Spring Boot | camel-spring-boot/ | Spring Boot 3.5.13 + camel-keycloak 4.22.1 |
| Camel Quarkus | camel-quarkus/ | Camel Quarkus 3.39.0 (Quarkus 3.39.0, Camel 4.22.0) |
On the introspection path, Keycloak reports a token as inactive once its exp has passed — so the
active flag is what enforces expiry. Introspection results are cached by default
(introspectionCacheEnabled=true, introspectionCacheTtl=60), and both shipped caches
(ConcurrentMapTokenCache, CaffeineTokenCache) expired an entry a fixed time after it was inserted,
without bounding that lifetime by the exp claim the cached response already carried.
KeycloakSecurityProcessor in turn checked only active and never compared exp with the current time.
A response cached shortly before the token expired was therefore served to later requests carrying the same
token, which were admitted with the roles and permissions the expired token had carried — although RFC 7662
states that a response containing exp must not be cached beyond that time.
cd camel-spring-boot # or: cd camel-quarkus
mvn clean package
docker compose up -d --build
until [ "$(curl -s http://localhost:8080/exploit/ready)" = "Started" ]; do sleep 1; done
curl -s http://localhost:8080/exploit/attack # takes ~4s: it waits for the token to expire
docker compose down
Output on an affected build (identical on both runtimes):
1) Request while the token is still valid
introspection calls so far: 1
outcome: [ADMITTED - the protected route ran]
... the token's exp has now passed. The cache entry is still inside its 60s TTL.
2) The same token again, now expired, against the SAME route
introspection calls so far: 1 (unchanged - the endpoint was never asked again)
outcome: [ADMITTED - the protected route ran]
an expired token was admitted: true
3) CONTROL - the same expired token against a route with introspectionCacheEnabled=false
introspection calls so far: 2 (the endpoint was asked)
outcome: [REJECTED - CamelAuthorizationException: Token is not active (may be revoked or expired)]
rejected once the endpoint was actually consulted: true
>>> PROVEN: ...
The introspection call counter is the evidence: it does not move between probe 1 and probe 2. The
identity provider was never asked a second time, so its verdict — which by then is "inactive" — never
reached the policy. Probe 3 proves that verdict exists: the same expired token against a route with
caching off is rejected, which is also the workaround the advisory gives for deployments that cannot
upgrade yet. Lowering introspectionCacheTtl only narrows the window; it does not close it.
No Keycloak server and no network. The app hosts a stand-in for Keycloak's introspection endpoint, on the
exact path KeycloakTokenIntrospector builds
({serverUrl}/realms/{realm}/protocol/openid-connect/token/introspect), which behaves as Keycloak does:
active until exp has passed, inactive afterwards, with exp, iss and realm_access.roles in the
response. The real KeycloakSecurityPolicy, the real KeycloakTokenIntrospector and the real cache run.
Introspection does not require the token to be a JWT, so the token here is an opaque string — the exp
that matters lives in the introspection response, which is exactly what the cache was failing to respect.
The issue grants nothing beyond what the token already carried: no token forgery and no privilege
escalation. The window is bounded by introspectionCacheTtl — 60 seconds by default, 300 to 600 where the
documentation's recommendation for internal services was followed — and it opens only for a token that the
same Camel process introspected and cached while it was still active. Token introspection is not enabled by
default. Hence the medium severity.
The local JWT verification path, used when introspection is disabled, is not affected.
| Property | Value |
|---|---|
| Component | camel-keycloak (KeycloakSecurityPolicy with useTokenIntrospection=true) |
| CWE | CWE-613 (Insufficient Session Expiration) |
| Severity | Medium |
| Attack vector | A caller re-presenting a bearer token that was valid moments earlier, within the introspection cache's TTL |
| Impact | Requests admitted after the token's expiry, with the roles and permissions that token carried |
| Affected Versions | From 4.16.0 before 4.18.5, from 4.19.0 before 4.22.2 |
| Fixed Versions | 4.18.5, 4.22.2, 4.23.0 |
| JIRA | CAMEL-24902 |
| Credit | Andrea Cosentino from Apache Software Foundation |
Advisory: https://camel.apache.org/security/CVE-2026-95663.html
Two independent changes:
introspectionCacheTtl
and the time remaining until the token's exp, and a result whose token has already expired is never
served from the cache.KeycloakSecurityProcessor now rejects a token whose exp lies in the past
on every path that uses introspection, so expiry is enforced there as it already was on the local
JWT verification path.A response that carries no exp claim keeps the configured time-to-live.
Fixed on main in apache/camel#26747 (7bfacb70), backported
to camel-4.22.x in #27182 (66fd7034) and to
camel-4.18.x in #27183 (6be02c19).
Set camel.version to 4.22.2 and run it again. Verified against 4.22.1 → 4.22.2:
2) The same token again, now expired, against the SAME route
introspection calls so far: 2
outcome: [REJECTED - CamelAuthorizationException: Token is not active (may be revoked or expired)]
an expired token was admitted: false
>>> NOT reproduced: the expired token was rejected.
Note the counter moving from 1 to 2: the cache entry's lifetime was bounded by the token's exp, so the
second request re-introspected and got the endpoint's real verdict.
This repository is published for educational and defensive purposes: to help Apache Camel users understand the vulnerability, verify whether they are affected, and confirm that upgrading resolves it. Do not use this material against systems you do not own or operate.