You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
OAuth TokenHandler should check Authorization header for client credentials #1315
Currently, TokenHandlerassumes that the Token request's body contains client credentials. However, some OAuth requests would contain client credentials in Authorization header:
In this case, it would throw ValidationError even though client credentials are provided in request header.
Can we add a fallback such that if client_id is not found in formData, we try to get it from header? e.g.
async def handle(self, request: Request):
try:
form_data = dict(await request.form())
# Try to get client credentials from header if missing in body
if "client_id" not in form_data:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Basic "):
encoded = auth_header.split(" ")[1]
decoded = base64.b64decode(encoded).decode("utf-8")
client_id, _, client_secret = decoded.partition(":")
client_secret = urllib.parse.unquote(client_secret)
form_data.setdefault("client_id", client_id)
form_data.setdefault("client_secret", client_secret)
token_request = TokenRequest.model_validate(form_data).root
except ValidationError as validation_error:
return self.response(
TokenErrorResponse(
error="invalid_request",
error_description=stringify_pydantic_error(validation_error),
)
)
...
I encountered the same issue you've experienced when I was testing FastMCP server with:
npx @modelcontextprotocol/inspector
The server responds with 401, says "Missing client_id".
Seems like the indicator tries to get token by providing client_id and client_secret via the auth header, but the server only tries to find client credentials from the request body, and fails.
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_request allows duplicated client_id and client_secret on header and request body.
If both are provided, it checks whether the client_id at the header is same as the client_id at the request body, which is not described at RFC and is an unknown behaviour.
If client_id and client_secret is both given at header and request body, the server should throw error, indicates that the auth method should be unique.
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::handle and mcp.server.auth.middleware.ClientAuthenticator::authenticate_request ALWAYS check client_id from the form data.
Client may send client_id only via header, not the request body.
client_id must 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_id discovery and form data.
From the PR:
Tries to retrieve client_id from headers first, then from form data if failed, at mcp.server.auth.middleware.ClientAuthenticator::authenticate_request.
Refer client_id from client not form data at mcp.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.
However once the issue was solved in google-adk, I found that python-sdk (via FastMCP) requires the client_id to 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_post method in google-adk, however the RFC 6749 says this about client_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.
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_id parameter 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_id in 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 whether client_id should be in the Token Request body when client_secret_basic is used.
We hit this in production today with Snowflake's AI-connectors MCP client, which uses Apache-HttpClient and authenticates strictly with Authorization: Basic (no client_id in 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_basic optional for servers. So an SDK could legitimately choose not to support Basic auth — but this SDK explicitly does support it:
build_metadata() advertises client_secret_basic in token_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 ClientAuthenticator has a client_secret_basic branch at mcp/server/auth/middleware/client_auth.py:67.
But it bails earlier at line 58 with Missing client_id if the body doesn't also carry client_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_id in 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:
# 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 though client_secret_basic is 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_basic once dynamic registration hands it a secret, so its token request carries the credentials only in the Authorization: Basic header and omits client_id from the form body. ClientAuthenticator.authenticate reads the body first and raises Missing client_id before 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_id from 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.py only issues a client secret when token_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.py needs the same treatment. Even with client_auth.py accepting Basic credentials, TokenRequest.model_validate(dict(form_data)) still fails validation on the missing client_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_method to client_secret_basic and 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:
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 authentication
And with client_auth.py reading the Authorization header before requiring the body value, plus token.py filling in the authenticated client id before TokenRequest.model_validate, the first case proceeds as well. Both halves are needed: fixing client_auth.py alone still fails validation in token.py.
Apologies for the noise in the earlier comment. Narrower conclusion: #1847's scope looks right as it stands.
Still reproduces on main today. ClientAuthenticator.authenticate_request reads the form body and bails before it ever looks at the Authorization header:
The part that makes it feel clearly unintended is what comes next. About twenty lines further down, the client_secret_basic branch 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 omit client_id from 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_id in the token form. Against a self-hosted FastMCP-based server it fails the token exchange with invalid_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 mcp package 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_id optional 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.
Activity
feat(auth): add Authorization header fallback for OAuth client creden…
feat: Add Authorization header fallback for OAuth TokenHandler
challenger71498 commentedon Jan 11, 2026
Hi @Mars9934,
I encountered the same issue you've experienced when I was testing FastMCP server with:
The 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:
Authorization: Basic ...) and with form data are mutual exclusive. Only one auth method should be used at a time.mcp.server.auth.middleware.ClientAuthenticator::authenticate_requestallows duplicatedclient_idandclient_secreton header and request body.client_idat the header is same as theclient_idat the request body, which is not described at RFC and is an unknown behaviour.client_idandclient_secretis both given at header and request body, the server should throw error, indicates that the auth method should be unique.mcp.server.auth.handlers.TokenHandler::handleandmcp.server.auth.middleware.ClientAuthenticator::authenticate_requestALWAYS checkclient_idfrom the form data.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:
client_idfrom headers first, then from form data if failed, atmcp.server.auth.middleware.ClientAuthenticator::authenticate_request.client_idfrom client not form data atmcp.server.auth.handlers.TokenHandler::handle.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.
joar commentedon Mar 26, 2026
I have discovered this issue as well in google/adk-python#4782, in summary
Using Slack's MCP server with google-adk:
client_idinPOST /tokenbody; soPOST /tokenwithAuthorization: Basic …, Slack assumes that the client is attempting to useclient_secret_postmethod, andclient_secretin the request body.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:a possible workaround is to use the
client_secret_postmethod in google-adk, however the RFC 6749 says this aboutclient_secret_post: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:
client_secret_post, andbut 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:My interpretation of this is that when:
client_idparameter in the Token Request body)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.rjolaverria commentedon May 13, 2026
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).token_endpoint_auth_method: "client_secret_basic"and issues a matching client.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:Hit
/oauth/tokentwo ways with that client: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.
oliver-mee commentedon Aug 18, 2026
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.
oliver-mee commentedon Aug 18, 2026
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: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:
And 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.
oliver-mee commentedon Aug 25, 2026
Still reproduces on
maintoday.ClientAuthenticator.authenticate_requestreads the form body and bails before it ever looks at the Authorization header: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.