Repository navigation
OAuth TokenHandler should check Authorization header for client credentials #1315
Description
Activity
- added 2 commits that reference this issue
on Aug 27, 2025 - addedauthIssues and PRs related to Authentication / OAuthIssues and PRs related to Authentication / OAuthenhancementRequest for a new feature that's not currently supportedRequest for a new feature that's not currently supported
on Oct 6, 2025 Hi @Mars9934,
I encountered the same issue you've experienced when I was testing FastMCP server with:
npx @modelcontextprotocol/inspectorThe server responds with 401, says "Missing client_id".
Seems like the indicator tries to get token by providing
client_idandclient_secretvia the auth header, but the server only tries to find client credentials from the request body, and fails.As you mentioned, the current implementation of token handling violates the OAuth2 protocol RFC 6749 - Client Authentication.
Based on RFC 6749:
The client MUST NOT use more than one authentication method in each request.
- Authentication with headers (
Authorization: Basic ...) and with form data are mutual exclusive. Only one auth method should be used at a time.- Normally the header is preferred, the form data is used only if the client cannot support the auth header.
mcp.server.auth.middleware.ClientAuthenticator::authenticate_requestallows duplicatedclient_idandclient_secreton header and request body.- If both are provided, it checks whether the
client_idat the header is same as theclient_idat the request body, which is not described at RFC and is an unknown behaviour. - If
client_idandclient_secretis both given at header and request body, the server should throw error, indicates that the auth method should be unique.
- If both are provided, it checks whether the
The authorization server MUST support the HTTP Basic authentication scheme for authenticating clients that were issued a client password
- AS-IS,
mcp.server.auth.handlers.TokenHandler::handleandmcp.server.auth.middleware.ClientAuthenticator::authenticate_requestALWAYS checkclient_idfrom the form data. - Client may send
client_idonly via header, not the request body. client_idmust be able to be retrieved even if it is not exist at the request body, when it exists at the header.
I am currently working on this issue, and going to create PR soon, which separates
client_iddiscovery and form data.From the PR:
- Tries to retrieve
client_idfrom headers first, then from form data if failed, atmcp.server.auth.middleware.ClientAuthenticator::authenticate_request. - Refer
client_idfrom client not form data atmcp.server.auth.handlers.TokenHandler::handle. - Throws if auth method used in request and registered auth method in the client are not equal.
You may look into the PR after I create it, if you are interested in.
Please let me know if there is a correction needed.
Your feedback is greatly appreciated :)
Thank you.Reacted by Joar Wandborg- Authentication with headers (
I have discovered this issue as well in google/adk-python#4782, in summary
Using Slack's MCP server with google-adk:- google-adk always included (prior to the fix)
client_idinPOST /tokenbody; so - when
POST /tokenwithAuthorization: Basic …, Slack assumes that the client is attempting to useclient_secret_postmethod, and - returns an error about missing
client_secretin the request body. - another user also reported the same issue for google-adk when trying to authencitate with Okta: OpenAPIToolset ERROR - oauth2_credential_exchanger.py:208 - Failed to exchange authorization code google/adk-python#4850
However once the issue was solved in google-adk, I found that python-sdk (via FastMCP) requires the
client_idto be present in the body. So it seems to me that google-adk has a choice here:- be compliant with RFC 6749 and compatible with Slack and Okta; or
- be compatible with MCPs using python-sdk
a possible workaround is to use the
client_secret_postmethod in google-adk, however the RFC 6749 says this aboutclient_secret_post:Including the client credentials in the request-body using the two
parameters is NOT RECOMMENDED and SHOULD be limited to clients unable
to directly utilize the HTTP Basic authentication scheme (or other
password-based HTTP authentication schemes).
— https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1
However, the MCP protocol seems to use OAuth 2.1 draft 13.
OAuth 2.1 draft 13 flips it around a bit in https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#section-2.4.1 so that:
- servers MUST implement
client_secret_post, and - servers MAY implement HTTP basic auth.
but in https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#section-3.2.2 of the same draft, it says this about
client_idin the body:- "client_id":
- OPTIONAL. The client identifier is needed when a form of client authentication that relies on the parameter is used, or the grant_type requires identification of public clients.
My interpretation of this is that when:
- use a confidential client (not a public client)
The client identifier is needed when a form of client authentication that relies on the parameter is used,
or the grant_type requires identification of public clients.- use client_secret_basic (not a form of client authentication that relies on the
client_idparameter in the Token Request body)
The client identifier is needed when
a form of client authentication that relies on the parameter is used,or the grant_type requires identification of public clients.then the Authorization Server has no reason require
client_idin Token Request body, so the solution in #1847, in addition to following RFC 6749, seems like a reasonable implementation of OAuth 2.1 draft 13, as they both seem to agree on whetherclient_idshould be in the Token Request body whenclient_secret_basicis used.Reacted by Mason Oh- google-adk always included (prior to the fix)
We hit this in production today with Snowflake's AI-connectors MCP client, which uses Apache-HttpClient and authenticates strictly with
Authorization: Basic(noclient_idin the body). Posting our reproduction here since it strengthens the case for prioritizing #1847.Framing
The MCP spec (2025-06-18) normatively references OAuth 2.1 draft-13, which makes
client_secret_basicoptional for servers. So an SDK could legitimately choose not to support Basic auth — but this SDK explicitly does support it:build_metadata()advertisesclient_secret_basicintoken_endpoint_auth_methods_supported(mcp/server/auth/routes.py:168).- DCR registration accepts
token_endpoint_auth_method: "client_secret_basic"and issues a matching client. - The
ClientAuthenticatorhas aclient_secret_basicbranch atmcp/server/auth/middleware/client_auth.py:67.
But it bails earlier at line 58 with
Missing client_idif the body doesn't also carryclient_id— so the Basic branch is unreachable in the only scenario it's designed for. Under OAuth 2.1 draft-13 §3.2.2,client_idin the body is OPTIONAL when the auth method doesn't rely on it (Basic doesn't), so this hard requirement is incorrect.Reproduction (no Snowflake needed)
Against a FastMCP 3.2.4 / mcp-sdk 1.27.0 server with an
Auth0Provider:-
Register a DCR client and capture its
client_id/client_secret:curl -s -X POST https://<server>/oauth/register -H 'Content-Type: application/json' -d '{ "client_name": "probe", "redirect_uris": ["https://example.com/cb"], "token_endpoint_auth_method": "client_secret_basic", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"] }' -
Hit
/oauth/tokentwo ways with that client:# client_secret_basic — RFC 6749 §2.3.1 recommended form, optional but supported per OAuth 2.1 curl -i -X POST https://<server>/oauth/token \\ -u "\$CID:\$CSEC" \\ -d 'grant_type=authorization_code&code=x&redirect_uri=https://example.com/cb&code_verifier=x' # → HTTP/2 401 {"error":"invalid_client","error_description":"Missing client_id"} # client_secret_post — credentials duplicated into the body curl -i -X POST https://<server>/oauth/token \\ -d "grant_type=authorization_code&code=x&redirect_uri=https://example.com/cb&code_verifier=x&client_id=\$CID&client_secret=\$CSEC" # → HTTP/2 401 {"error":"invalid_grant","error_description":"authorization code does not exist"}The second response shows client authentication succeeded (it now fails on the fake code as expected). The first never gets past `ClientAuthenticator.authenticate_request` line 58.
Impact
Every real-world client that uses the Basic header without redundantly populating the body — Snowflake's MCP connector, Slack's MCP server (reported above by @joar), Okta-fronted MCPs — fails at the token exchange. The server currently only works with clients that choose
client_secret_post, even thoughclient_secret_basicis fully advertised.#1847 looks like the right fix. CI is green across 22 jobs, last touched ~6 weeks ago — would love a maintainer review.
Another production report, this time with a different client, plus a note on how it interacts with DCR.
Client: an OpenAI Codex MCP client connecting to a FastMCP 3.4.7 server (mcp 1.28.1). Codex uses standard
client_secret_basiconce dynamic registration hands it a secret, so its token request carries the credentials only in theAuthorization: Basicheader and omitsclient_idfrom the form body.ClientAuthenticator.authenticatereads the body first and raisesMissing client_idbefore it ever looks at the header, so the exchange fails at that point.RFC 6749 section 2.3.1 is explicit that a client authenticating with HTTP Basic may omit
client_idfrom the request body, so this is a spec-compliance gap rather than a client quirk.Two things that might be useful for whoever picks up #1847:
The DCR side needs to agree with the token side.
register.pyonly issues a client secret whentoken_endpoint_auth_method != "none". A client that registers as public but then chooses Basic ends up on the failing path, so fixing the token handler alone still leaves a mismatch depending on what registration returned. Normalising the registration response and the token parser together is what made it work here.token.pyneeds the same treatment. Even withclient_auth.pyaccepting Basic credentials,TokenRequest.model_validate(dict(form_data))still fails validation on the missingclient_id, so the authenticated client id has to be filled in before validation.I have a working local patch covering all three files, currently applied at container build time as a stopgap. #1847 looks like the right home for the fix and it is what I would rather see merged, so I am not opening a competing PR. Happy to contribute the missing pieces there, or to open a rebased PR crediting the original if that branch has gone quiet, whichever the maintainers prefer.
Correcting part of my earlier comment, now that I have tested it properly rather than inferred it.
I said the DCR side needs fixing alongside the token handler. On fastmcp 3.4.7 that turns out to be wrong, and only the token-side fix matters.
I had been forcing
token_endpoint_auth_methodtoclient_secret_basicand issuing a secret at registration, which was necessary on 3.4.4. Registering against a build without that change on 3.4.7 shows it is no longer needed:client requests token_endpoint_auth_method=client_secret_basic -> returns client_secret_basic, secret issued client requests token_endpoint_auth_method=none -> returns none, no secret (public client)So registration already does the right thing in both directions. I have removed that part locally and reconnected the client that originally needed it, which works.
What is still required is the part this issue is actually about. On an unpatched build:
Basic auth, no client_id in body -> {"error":"invalid_client","error_description":"Missing client_id"} Basic auth, client_id in body -> proceeds past client authenticationAnd with
client_auth.pyreading the Authorization header before requiring the body value, plustoken.pyfilling in the authenticated client id beforeTokenRequest.model_validate, the first case proceeds as well. Both halves are needed: fixingclient_auth.pyalone still fails validation intoken.py.Apologies for the noise in the earlier comment. Narrower conclusion: #1847's scope looks right as it stands.
Still reproduces on
maintoday.ClientAuthenticator.authenticate_requestreads the form body and bails before it ever looks at the Authorization header:form_data = await request.form() client_id = form_data.get("client_id") if not client_id: raise AuthenticationError("Missing client_id")
The part that makes it feel clearly unintended is what comes next. About twenty lines further down, the
client_secret_basicbranch decodes the header and URL-decodes both halves, citing RFC 6749 Section 2.3.1 in a comment. That is the same section which says a client authenticating with HTTP Basic MAY omitclient_idfrom the request body. So the file already knows the rule it is breaking four lines earlier.Another data point on who this hits, since the report above mentions FastMCP: the OpenAI Codex CLI authenticates exactly this way, sending Basic credentials with no
client_idin the token form. Against a self-hosted FastMCP-based server it fails the token exchange withinvalid_client: Missing client_id, and no amount of server-side configuration gets around it, because the request never reaches the branch that would accept it. That is now at least three independent reports of the same path.The workaround people end up with is patching the installed
mcppackage at image build time, which is exactly as brittle as it sounds. It has to assert its anchor text and fail the build loudly, because the SDK moving those lines would otherwise silently produce an image with broken OAuth.@challenger71498's #1847 looks like the right shape for this: read the header first, fall back to the form, and make
client_idoptional in the body when Basic is used. It has gone conflicted since May though, which seems to be what is keeping the bug alive rather than any disagreement about the fix. Happy to help rebase or test it against a real Basic-auth client if that would move it along.
Description
Currently,

TokenHandlerassumes that the Token request's body contains client credentials. However, some OAuth requests would contain client credentials inAuthorizationheader:In this case, it would throw
ValidationErroreven though client credentials are provided in request header.Can we add a fallback such that if
client_idis not found informData, we try to get it from header? e.g.Thanks.
References
No response