Skip to content

OAuth TokenHandler should check Authorization header for client credentials #1315

Description

@Mars9934

Description

Currently, TokenHandler assumes that the Token request's body contains client credentials. However, some OAuth requests would contain client credentials in Authorization header:
Image

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),
            )
        )
    ...

Thanks.

References

No response

Activity

  1. added 2 commits that reference this issue on Aug 27, 2025
    88f6ccb
    55d8b42
  2. added
    authIssues and PRs related to Authentication / OAuth
    enhancementRequest for a new feature that's not currently supported
    on Oct 6, 2025
  3. challenger71498 commented on Jan 11, 2026

    @challenger71498

    Hi @Mars9934,

    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.

    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_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.

  4. joar commented on Mar 26, 2026

    @joar

    I have discovered this issue as well in google/adk-python#4782, in summary
    Using Slack's MCP server with google-adk:

    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:

    1. be compliant with RFC 6749 and compatible with Slack and Okta; or
    2. 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.

    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_id in 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_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.

  5. rjolaverria commented on May 13, 2026

    @rjolaverria

    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:

    1. 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"]
      }'
      
    2. Hit /oauth/token two 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 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.

  6. oliver-mee commented on Aug 18, 2026

    @oliver-mee

    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.

  7. oliver-mee commented on Aug 18, 2026

    @oliver-mee

    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:

    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 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.

  8. oliver-mee commented on Aug 25, 2026

    @oliver-mee

    Still reproduces on main today. ClientAuthenticator.authenticate_request reads 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_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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

authIssues and PRs related to Authentication / OAuthenhancementRequest for a new feature that's not currently supported

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions