Repository navigation
OAuth client sends client_id in token body under client_secret_basic (strict servers reject as multiple auth methods) #3138
Description
Activity
Confirmed. Here is the root cause and the fix.
Root cause
In
src/mcp/client/auth/oauth2.py,prepare_token_authforclient_secret_basicstrips onlyclient_secretfrom the request body but leavesclient_idin:headers["Authorization"] = f"Basic {encoded_credentials}" # Don't include client_secret in body for basic auth data = {k: v for k, v in data.items() if k != "client_secret"} # client_id still present
RFC 6749 §2.3 states that when using HTTP Basic Authentication the client credentials must not be included in the request body. Strict token endpoints (including Keycloak, Okta in strict mode, and the RFC 6749 compliance test suite) reject requests where credentials appear in both the
Authorizationheader and the body.Fix (one character change)
data = {k: v for k, v in data.items() if k not in ("client_secret", "client_id")}
This matches the
client_secret_postbranch's logic in reverse: that branch explicitly sets both in the body;client_secret_basicshould explicitly exclude both from the body.Happy to open a PR if that would be useful.
Hi, I'd like to work on this — I have a fix ready (with AI assistance, disclosed per
CONTRIBUTING.md). prepare_token_auth() now strips client_id from the body alongside
client_secret when token_endpoint_auth_method is client_secret_basic, matching RFC 6749
§2.3. Two existing tests in tests/client/test_auth.py were actually asserting the old
(buggy) behavior explicitly — I've updated them to assert the corrected one. Could this
be assigned to me?Path-1 reliability note on OAuth token-endpoint client auth ambiguity:
When a client speaks
client_secret_basicand still putsclient_idin the form body, strict servers correctly reject the request as multi-auth / malformed. In production agent loops this often surfaces as a flaky “auth works in Postman, fails in the worker” symptom — not a model problem.Control-plane checklist that usually isolates it:
- One auth channel only — either Basic or body credentials; never both. Treat dual-send as a hard gate before the token POST.
- Durable expected-vs-actual — log (redacted) which auth style was chosen + the exact HTTP status/error code; do not retry with a different model or tool fan-out.
- Idempotent token refresh — one in-flight refresh per client identity; concurrent refreshes under dual-auth bugs mint thrash and look like random 401s.
- Policy before action — refuse tool fan-out while the token exchange is in an unclassified failure state.
If you can share a sanitized token-endpoint response (status + error body, no secrets), happy to help map whether this is client construction vs server strictness. Architecture only.
- added a commit that references this issue
on Aug 8, 2026 - addedbugSomething isn't workingSomething isn't workingP1Significant bug affecting many users, highly requested featureSignificant bug affecting many users, highly requested featureneeds confirmationNeeds confirmation that the PR is actually required or needed.Needs confirmation that the PR is actually required or needed.authIssues and PRs related to Authentication / OAuthIssues and PRs related to Authentication / OAuthv1Affects the v1.x maintenance lineAffects the v1.x maintenance linev2Affects the v2 line (2.x on main)Affects the v2 line (2.x on main)
on Aug 14, 2026 I believe this issue is a duplicate of #1315
Not a duplicate of #1315.
#1315 is the server TokenHandler: it currently requires
client_idin the form body and 400s when the client only sent HTTP Basic.This issue is the client
prepare_token_auth:client_secret_basicstill leavesclient_idin the body while also sending Basic, so a strict token endpoint (Keycloak / Okta strict mode / RFC 6749 test suite) rejects the request as multiple auth methods.Opposite sides of RFC 6749 §2.3. Closing this as a dupe of 1315 would leave the SDK still sending both. The one-line strip of
client_idfrom the body (already sketched above) is the client-side fix; 1315 is the server-side fallback.Not a duplicate of #1315.
#1315 is the server TokenHandler: it currently requires
client_idin the form body and 400s when the client only sent HTTP Basic.This issue is the client
prepare_token_auth:client_secret_basicstill leavesclient_idin the body while also sending Basic, so a strict token endpoint (Keycloak / Okta strict mode / RFC 6749 test suite) rejects the request as multiple auth methods.Opposite sides of RFC 6749 §2.3. Closing this as a dupe of 1315 would leave the SDK still sending both. The one-line strip of
client_idfrom the body (already sketched above) is the client-side fix; 1315 is the server-side fallback.You're right, I drew my conclusion too quickly.
Initial Checks
Description
Under
token_endpoint_auth_method = client_secret_basic, the OAuth client sends the client credentials in the HTTPAuthorization: Basicheader and also leavesclient_idin the token-exchange POST body. Strict token endpoints reject this as two authentication methods in a single request.OAuthClientProvider.prepare_token_auth(src/mcp/client/auth/oauth2.py) strips onlyclient_secretfrom the body:Expected: with Basic auth, the request presents exactly one authentication method — the
Authorizationheader — with no client credentials or identifier duplicated in the body.Actual:
client_idstays in the body.Against Notion's MCP server (
https://mcp.notion.com/mcp), where dynamic client registration returnstoken_endpoint_auth_method = client_secret_basic, the token exchange fails with:There is also a secondary, misleading cascade (not the root cause): the failed first attempt starts a second OAuth flow that reuses the loopback callback port, and stale browser tabs redirect old approvals into it, surfacing
OAuthFlowError: State parameter mismatch.I want to frame this as an interop issue rather than a compliance accusation. RFC 6749 §2.3.1 presents the HTTP Basic header and body credentials as alternatives; sending only the header is unambiguously valid, removes the ambiguity, and loses no information (the
client_idis already present, base64-encoded, in the Basic header). Strippingclient_idfrom the body makes the SDK work against strict servers like Notion at no cost to lenient ones.The one-line fix in the
client_secret_basicbranch:Note: the existing test
test_basic_auth_token_exchangecurrently assertsclient_id ... in content # client_id still in body— this behaviour was codified in the same commit that introduced Basic auth support (#1334), so the fix inverts that assertion.Update: this is the CLIENT half — it depends on the server side (#1847)
The one-line client change cannot land alone. Body
client_idis load-bearing on this SDK's own server side in three places, so a client that omits it fails against any server built on this SDK:ClientAuthenticator.authenticate_request(src/mcp/server/auth/middleware/client_auth.py) requiresclient_idin the body.TokenHandler's request models (AuthorizationCodeRequest/RefreshTokenRequest,src/mcp/server/auth/handlers/token.py) declareclient_id: stras required — a missing value returns a Pydantic400 invalid_request: client_id: Field required.auth_code.client_id != token_request.client_id(and the refresh equivalent).The matching server-side change is exactly what #1847 already implements: it makes body
client_idoptional, re-sources the ownership checks from the authenticatedclient_info.client_id, and readsclient_idfrom the Basic header. So the two are complementary halves of one fix:Missing client_id. Verified.mainand running the client + interaction OAuth suites (green). The only residual failures were feat: fully support Basic Authorization header at token request #1847's own error-message test updates, which appear stranded by atests/server/fastmcp→tests/server/mcpserverdirectory rename onmain(i.e. feat: fully support Basic Authorization header at token request #1847 looks like it needs a rebase) — unrelated to the client change.Questions for maintainers:
main(v2), and/or av1.xbackport? The live breakage is on v1 (1.27.x) via Notion.I'm happy to open the client-side PR (one-line change + updated tests) once there's buy-in and a preferred sequencing.
Example Code
Python & MCP Python SDK