apereo/cas · error · ResponseStatusException
Client id [ ] in logout request does not match client id […
Error message
Client id [{}] in logout request does not match client id [{}] in ID token What it means
During OIDC RP-initiated logout, CAS decodes the ID token and extracts its client_id claim. If the caller supplied a client_id parameter (or it was derived from the request) that differs from the client_id inside the ID token, CAS throws ResponseStatusException(HTTP 400) with the 'screen.oidc.issuer.invalid' message — the logout request is treated as tampered/mismatched.
Solutions
- Ensure the client_id parameter exactly equals the aud/azp client in the id_token_hint
- Regenerate the ID token from the correct client after re-registering/renaming services
- Omit the client_id parameter when posting an id_token_hint if the RP cannot guarantee the match
- Verify via OAuth20Utils.getRegisteredOAuthServiceByClientId that the intended service is registered and enabled
Example fix
// before GET /cas/oidc/oidcLogout?client_id=oldClient&id_token_hint=<token with azp=newClient> // after GET /cas/oidc/oidcLogout?client_id=newClient&id_token_hint=<token with azp=newClient>
Defensive patterns
Strategy: validation
Validate before calling
const claims = decodeJwtClaims(idTokenHint);
if (clientId && claims.azp !== clientId && !(claims.aud ?? []).includes(clientId)) {
throw new Error(`client_id ${clientId} does not match ID token aud/azp`);
} Type guard
function idTokenMatchesClient(idToken, clientId) {
const claims = decodeJwtClaims(idToken);
return claims.azp === clientId || (Array.isArray(claims.aud) ? claims.aud.includes(clientId) : claims.aud === clientId);
} Try / catch
try {
await cas.logout({ clientId, idTokenHint });
} catch (e) {
if (e.status === 400) {
// client_id vs ID token mismatch: drop client_id or re-issue the token
}
throw e;
} Prevention
- Store and send the same client id used to obtain the ID token
- Re-fetch ID tokens after renaming/re-registering clients
- Omit client_id when unsure and rely on id_token_hint alone
- Keep service registrations' ids stable
When it happens
Trigger: GET /oidc/oidcLogout (or logout) where the 'client_id' request parameter is non-blank and case-insensitively different from the client_id claim embedded in the posted/referenced id_token_hint.
Common situations: RPs storing a stale or different client id (renamed/re-registered service); passing the wrong client's id_token_hint; multiple client registrations with similar ids; hand-built logout URLs copying the wrong parameter.
Related errors
- screen.oidc.issuer.invalid
- Unable to use 'none' as ID token signing algorithm
- Unable to use 'none' as ID token encryption algorithm
- No (successful) logout response received from the url
- Authentication request does not include the
AI-assisted analysis of apereo/cas@e7288fc434 (2026-09-08).
Data as JSON: /api/errors/3c64d926ee353eef.
Report an issue: GitHub.
Appendix: source
Thrown at support/cas-server-support-oidc-core-api/src/main/java/org/apereo/cas/oidc/web/controllers/logout/OidcLogoutEndpointController.java:103
public ResponseEntity handleRequestInternal(
@RequestParam(value = OidcConstants.POST_LOGOUT_REDIRECT_URI, required = false)
final String postLogoutRedirectUrl,
@RequestParam(value = OAuth20Constants.STATE, required = false)
final String state,
@RequestParam(value = OAuth20Constants.CLIENT_ID, required = false)
final String givenClientId,
@RequestParam(value = OidcConstants.ID_TOKEN_HINT, required = false)
final String idToken,
final HttpServletRequest request, final HttpServletResponse response) throws Throwable {
if (StringUtils.isNotBlank(idToken)) {
LOGGER.trace("Decoding logout ID token [{}]", idToken);
val clientIdInIdToken = OAuth20Utils.extractClientIdFromToken(idToken);
LOGGER.debug("Client id retrieved from ID token is [{}]", clientIdInIdToken);
if (StringUtils.isNotBlank(givenClientId) && !Strings.CI.equals(givenClientId, clientIdInIdToken)) {
LOGGER.warn("Client id [{}] in logout request does not match client id [{}] in ID token", givenClientId, clientIdInIdToken);
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
configurationContext.getMessageSource().getMessage("screen.oidc.issuer.invalid", ArrayUtils.EMPTY_OBJECT_ARRAY, request.getLocale()));
}
val registeredService = OAuth20Utils.getRegisteredOAuthServiceByClientId(
getConfigurationContext().getServicesManager(), clientIdInIdToken, OidcRegisteredService.class);
val idTokenClaims = getConfigurationContext().getIdTokenSigningAndEncryptionService().decode(idToken, Optional.of(registeredService));
Assert.isTrue(idTokenClaims.getClaimValueAsString(OAuth20Constants.CLIENT_ID).equalsIgnoreCase(registeredService.getClientId()),
"Client id in ID token does not match client id in registered service");
Assert.isTrue(idTokenClaims.hasClaim(OidcConstants.AUD), "Audience claim is not present");
Assert.isTrue(idTokenClaims.hasClaim(OAuth20Constants.CLAIM_SUB), "Subject claim is not present");
LOGGER.debug("Located registered service [{}]", registeredService);
val service = getConfigurationContext().getWebApplicationServiceServiceFactory().createService(clientIdInIdToken);
val audit = AuditableContext.builder()
.service(service)
.registeredService(registeredService)
.build();
val accessResult = getConfigurationContext().getRegisteredServiceAccessStrategyEnforcer().execute(audit);View on GitHub (pinned to e7288fc434)