{"record":{"id":"6aa6e07edb86495c","repo":"BerriAI/litellm","slug":"idp-rejected-the-subject-token-http-status-code","errorCode":null,"errorMessage":"IdP rejected the subject token (HTTP {status_code})","messagePattern":"IdP rejected the subject token \\(HTTP (.+?)\\)","errorType":"exception","errorClass":"SubjectTokenRejected","httpStatus":401,"severity":"error","filePath":"litellm/proxy/_experimental/mcp_server/outbound_credentials/token_exchange_provider.py","lineNumber":92,"sourceCode":"    headers: Final = {\"Accept\": \"application/json\", **client_auth_headers}\n    try:\n        client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP)  # pyright: ignore\n        response: Final = await client.post(url, headers=headers, data=form)  # pyright: ignore\n        response.raise_for_status()  # pyright: ignore\n        parsed: Final[object] = response.json()  # pyright: ignore\n    except httpx.HTTPStatusError as status_err:\n        status_code: Final = status_err.response.status_code\n        if 400 <= status_code < 500:\n            oauth_error, claims = _oauth_error_fields(status_err.response)\n            if oauth_error in _GATEWAY_FAULT_OAUTH_ERRORS:\n                verbose_logger.warning(\n                    \"MCP token exchange rejected as %s (HTTP %d); check the gateway client credentials, \"\n                    \"audience, and scope for this server\",\n                    oauth_error,\n                    status_code,\n                )\n                raise TokenExchangeClientError(oauth_error) from status_err\n            raise SubjectTokenRejected(\n                f\"IdP rejected the subject token (HTTP {status_code})\",\n                claims=claims,\n            ) from status_err\n        verbose_logger.warning(\"MCP token exchange request failed: %s\", status_err)\n        return None\n    except Exception as exc:  # noqa: BLE001\n        verbose_logger.warning(\"MCP token exchange request failed: %s\", exc)\n        return None\n    if not isinstance(parsed, dict):\n        # A valid-but-non-object JSON body (list/string/number) would crash the field parsing; map it\n        # to a miss so it surfaces as a typed upstream_unavailable, not a 500.\n        verbose_logger.warning(\"MCP token exchange returned non-object JSON (%s)\", type(parsed).__name__)\n        return None\n    return parsed  # pyright: ignore\n\n\ndef build_token_exchanger() -> OboTokenExchanger:\n    return OboTokenExchanger(","sourceCodeStart":74,"sourceCodeEnd":110,"githubUrl":"https://github.com/BerriAI/litellm/blob/77b7c6c40c0c5aa5fbcb1d6a1825ac39ca8829b8/litellm/proxy/_experimental/mcp_server/outbound_credentials/token_exchange_provider.py#L74-L110","documentation":"During RFC 8693 token exchange for an MCP server, the IdP's token endpoint returned a 4xx whose OAuth error code indicts the caller's subject token (e.g. invalid_grant from an expired/revoked refresh token or withdrawn consent) rather than the gateway's own credentials (invalid_client, unauthorized_client, unsupported_grant_type, invalid_target, invalid_scope map to a 500 instead). The proxy wraps it as SubjectTokenRejected and surfaces a 401 to the caller, preserving any IdP step-up claims blob.","triggerScenarios":"Per-user/delegated token exchange where the user's upstream token expired, was revoked, or the IdP demands step-up: POST to the token endpoint answers 400/401 with invalid_grant (or similar non-gateway code) and this exception replaces the credential with a typed rejection.","commonSituations":"Entra ID Conditional Access/CAE requiring re-authentication; refresh tokens past the rotation window for inactive users; tenant admin revoked the OAuth application's consent; subject token audience changed after an IdP migration.","solutions":["Re-authenticate the user against the upstream IdP (re-run the OAuth flow) to obtain a fresh subject token, then retry the tool call.","If the 401 payload carries a claims blob, replay the step-up challenge to the IdP (MSAL-style claims challenge) to satisfy Conditional Access.","Check IdP sign-in/audit logs for the exact rejection reason (revocation, CA policy, audience) if re-auth does not clear it."],"exampleFix":null,"handlingStrategy":"try-catch","validationCode":null,"typeGuard":null,"tryCatchPattern":"resp = await client.post(f\"{proxy}/mcp/tool-call\", json=payload, headers=headers)\nif resp.status_code == 401 and \"IdP rejected the subject token\" in resp.text:\n    claims = extract_claims(resp)  # step-up blob if the IdP sent one\n    await reauthenticate_user(claims=claims)   # re-run upstream OAuth (MSAL handles claims challenge)\n    resp = await client.post(f\"{proxy}/mcp/tool-call\", json=payload, headers=headers)\nresp.raise_for_status()","preventionTips":["Refresh delegated subject tokens on a schedule well inside the IdP's token lifetime.","Implement the claims-challenge (step-up) path in the client so Conditional Access prompts re-auth cleanly.","Monitor for repeated 401 token-exchange rejections per user - usually revoked consent needing manual re-auth."],"tags":["oauth","token-exchange","http-401","idp","invalid-grant","mcp"],"backgroundTag":"oauth-token-exchange-rejected","analyzedSha":"77b7c6c40c0c5aa5fbcb1d6a1825ac39ca8829b8","analyzedAt":"2026-08-18T11:44:31.656Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}