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

  1. Ensure the client_id parameter exactly equals the aud/azp client in the id_token_hint
  2. Regenerate the ID token from the correct client after re-registering/renaming services
  3. Omit the client_id parameter when posting an id_token_hint if the RP cannot guarantee the match
  4. 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

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


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)