ZaasClientIntegrationTest.java:334 — sends an empty string "" as the token to the logout endpoint and expects ZaasClientException. In modulith HA the logout returns 204 No Content instead.
How the Request Flows
Client side (ZaasJwtService.java:233): The empty token is sent as a cookie apimlAuthenticationToken= (or an empty Bearer header) with no client-side validation.
Security layer (NewSecurityConfiguration.java:148): The login and logout endpoints share one filter chain configured with .anyRequest().permitAll(). Spring Security does not enforce any token presence before the request reaches the logout handler.
Logout handler (JWTLogoutHandler.java:39): Calls getJwtTokenFromRequest(), which:
- Filters out cookies with empty values (!cookie.getValue().isEmpty())
- Returns Optional.empty() for an empty Bearer header
So with an empty token, token.isPresent() is false and the handler calls failure.onAuthenticationFailure(...) — this should produce an error response. This path works correctly in single-instance mode.
Why It Breaks in Modulith HA
The divergence is in what happens before reaching JWTLogoutHandler. In modulith HA there are multiple gateway instances. The request passes through the Gateway's filter chain on its way to ZAAS, and the key suspect is the token validation filter that runs on the Gateway side.
AuthenticationService.invalidateJwtToken() (AuthenticationService.java:188):
Application app = isModulithMode
? eurekaClient.getApplication(CoreService.GATEWAY.getServiceId()) // HA path
: eurekaClient.getApplication(CoreService.ZAAS.getServiceId());
In HA mode, invalidateTokenOnAnotherInstance() (AuthenticationService.java:285) iterates over all peer instances and calls DELETE /invalidate/ on each. With an empty token, this call either:
- Returns a non-error response from the peer (the peer also has no validation on that endpoint)
- Has its exception caught and silently swallowed at line 301–303
If the distributed call "succeeds" (peer returns 200/204 for an empty token), doInvalidate() returns Boolean.TRUE and the success handler writes 204 No Content — the logout appears to succeed.
In single instance, application.getInstances() only contains the current instance which is skipped (it's self), so returnValue stays true and then parseJwtToken("") throws a BadCredentialsException. In single instance, isInvalidatedOnAnotherInstance is false, so the exception is re-thrown at line 238. In HA, if a peer call actually completes (even incorrectly), isInvalidatedOnAnotherInstance becomes true, and the same BadCredentialsException is suppressed at line 236–238.
Root Cause (Precise)
In AuthenticationService.doInvalidate() (AuthenticationService.java:233):
} catch (BadCredentialsException e) {
if (!isInvalidatedOnAnotherInstance) {
throw e; // re-thrown in single instance
}
// silently swallowed in HA if a peer responded
}
When there is a peer in HA mode and the peer's /invalidate/ endpoint responds (even to an empty token), isInvalidatedOnAnotherInstance = true. The subsequent parseJwtToken("") throws BadCredentialsException, but the catch block suppresses it because isInvalidatedOnAnotherInstance is true. The method returns Boolean.TRUE, the success path runs, and the logout returns 204.
Key Files
│ File │ Line │ Issue │
│-------------------│-------│-----------------------------------------------------------│
│ NewSecurityConfiguration.java │ 148 │ .permitAll() — no auth enforcement at filter chain level │
│ JWTLogoutHandler.java │ 39–45 │ Correct for single-instance; never reached in HA bypass path │
│ AuthenticationService.java │ 233–238 │ Root cause — BadCredentialsException suppressed when peer responded │
│ AuthenticationService.java │ 285–309 │ Peer invalidation call succeeds for empty token, setting isInvalidatedOnAnotherInstance=true │
│ ZaasJwtService.java │ 233 │ No client-side empty-token guard │
Recommended Fix
The most targeted fix is in AuthenticationService.doInvalidate(): validate the token format before attempting peer distribution, not after. An empty or unparseable token should be rejected immediately, before isInvalidatedOnAnotherInstance is set:
private Boolean doInvalidate(String jwtToken, boolean distribute, Application app) {
// Validate token format first — before any peer calls
final QueryResponse queryResponse;
try {
queryResponse = parseJwtToken(jwtToken).getQueryResponse();
} catch (BadCredentialsException | TokenFormatNotValidException e) {
throw e; // always propagate — don't suppress based on HA state
}
boolean isInvalidatedOnAnotherInstance = false;
if (distribute) {
isInvalidatedOnAnotherInstance = invalidateTokenOnAnotherInstance(jwtToken, app);
if (!isInvalidatedOnAnotherInstance) {
return Boolean.FALSE;
}
}
// ... rest of invalidation logic
}
A defense-in-depth fix would also add an explicit empty/null check at the top of JWTLogoutHandler.logout() and in invalidateJwtToken() before any processing begins.
ZaasClientIntegrationTest.java:334 — sends an empty string "" as the token to the logout endpoint and expects ZaasClientException. In modulith HA the logout returns 204 No Content instead.
How the Request Flows
Client side (ZaasJwtService.java:233): The empty token is sent as a cookie apimlAuthenticationToken= (or an empty Bearer header) with no client-side validation.
Security layer (NewSecurityConfiguration.java:148): The login and logout endpoints share one filter chain configured with .anyRequest().permitAll(). Spring Security does not enforce any token presence before the request reaches the logout handler.
Logout handler (JWTLogoutHandler.java:39): Calls getJwtTokenFromRequest(), which:
So with an empty token, token.isPresent() is false and the handler calls failure.onAuthenticationFailure(...) — this should produce an error response. This path works correctly in single-instance mode.
Why It Breaks in Modulith HA
The divergence is in what happens before reaching JWTLogoutHandler. In modulith HA there are multiple gateway instances. The request passes through the Gateway's filter chain on its way to ZAAS, and the key suspect is the token validation filter that runs on the Gateway side.
In HA mode, invalidateTokenOnAnotherInstance() (AuthenticationService.java:285) iterates over all peer instances and calls DELETE /invalidate/ on each. With an empty token, this call either:
If the distributed call "succeeds" (peer returns 200/204 for an empty token), doInvalidate() returns Boolean.TRUE and the success handler writes 204 No Content — the logout appears to succeed.
In single instance, application.getInstances() only contains the current instance which is skipped (it's self), so returnValue stays true and then parseJwtToken("") throws a BadCredentialsException. In single instance, isInvalidatedOnAnotherInstance is false, so the exception is re-thrown at line 238. In HA, if a peer call actually completes (even incorrectly), isInvalidatedOnAnotherInstance becomes true, and the same BadCredentialsException is suppressed at line 236–238.
Root Cause (Precise)
In AuthenticationService.doInvalidate() (AuthenticationService.java:233):
When there is a peer in HA mode and the peer's /invalidate/ endpoint responds (even to an empty token), isInvalidatedOnAnotherInstance = true. The subsequent parseJwtToken("") throws BadCredentialsException, but the catch block suppresses it because isInvalidatedOnAnotherInstance is true. The method returns Boolean.TRUE, the success path runs, and the logout returns 204.
Key Files
│ File │ Line │ Issue │
│-------------------│-------│-----------------------------------------------------------│
│ NewSecurityConfiguration.java │ 148 │ .permitAll() — no auth enforcement at filter chain level │
│ JWTLogoutHandler.java │ 39–45 │ Correct for single-instance; never reached in HA bypass path │
│ AuthenticationService.java │ 233–238 │ Root cause — BadCredentialsException suppressed when peer responded │
│ AuthenticationService.java │ 285–309 │ Peer invalidation call succeeds for empty token, setting isInvalidatedOnAnotherInstance=true │
│ ZaasJwtService.java │ 233 │ No client-side empty-token guard │
Recommended Fix
The most targeted fix is in AuthenticationService.doInvalidate(): validate the token format before attempting peer distribution, not after. An empty or unparseable token should be rejected immediately, before isInvalidatedOnAnotherInstance is set:
A defense-in-depth fix would also add an explicit empty/null check at the top of JWTLogoutHandler.logout() and in invalidateJwtToken() before any processing begins.