Skip to content

release/3.x: CIMD private_key_jwt clients always rejected — expected aud has a doubled slash (fixed on main in #4659, unreleased on 3.x) #4789

Description

@kevyman

Problem

On release/3.x (through v3.4.6, the current pip install fastmcp), OAuthProxy builds the expected audience for a CIMD private_key_jwt client assertion as f"{self.base_url}/token" (fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py:2061). base_url is a pydantic AnyHttpUrl, which renders a bare origin with a trailing slash, so the proxy expects aud=https://host//token while its own metadata advertises token_endpoint=https://host/token. A spec-following client signs the advertised value, so every private_key_jwt token exchange fails with 401 invalid_client ("audience mismatch"). Clients whose CIMD document uses token_endpoint_auth_method: none are unaffected, which makes this look client-specific.

Real-world impact: ChatGPT's MCP connector uses CIMD with private_key_jwt, so 100% of ChatGPT logins fail against a stock bare-origin deployment while Claude (none) works. Hit this in production on 2026-08-08; server logs showed audience mismatch (got 'https://<host>/token', expected 'https://<host>//token').

This was fixed on main by #4659 (shipped in v4.0.0b1), but the fix never landed on release/3.xv3.4.5 was tagged hours after it merged without it, and v3.4.6 still has the original line. A backport PR already exists (#4783) and was auto-closed only for lack of a linked issue; this issue is filed to unblock it.

MRE

# fastmcp==3.4.6  (condensed from #4783's reproduction)
from unittest.mock import Mock

from key_value.aio.stores.memory import MemoryStore
from starlette.applications import Starlette
from starlette.testclient import TestClient

from fastmcp.server.auth.oauth_proxy import OAuthProxy
from fastmcp.server.auth.providers.jwt import JWTVerifier

verifier = Mock(spec=JWTVerifier)
verifier.required_scopes = []

proxy = OAuthProxy(
    upstream_authorization_endpoint="https://auth.example.com/authorize",
    upstream_token_endpoint="https://auth.example.com/token",
    upstream_client_id="client-123",
    upstream_client_secret="secret-456",
    token_verifier=verifier,
    base_url="https://api.example.com",  # bare origin — the common case
    jwt_signing_key="test-secret-value",
    client_storage=MemoryStore(),
)

app = Starlette(routes=proxy.get_routes(mcp_path="/mcp"))
with TestClient(app) as c:
    advertised = c.get("/.well-known/oauth-authorization-server").json()["token_endpoint"]

expected_aud = f"{proxy.base_url}/token"  # the expression proxy.py:2061 uses
print(advertised)    # https://api.example.com/token
print(expected_aud)  # https://api.example.com//token
assert advertised == expected_aud  # AssertionError on v3.4.6

Expected

The expected assertion audience equals the advertised token_endpoint, as on main since #4659.

Ask

Reopen/merge #4783 (a straight backport of #4659) and cut a 3.x patch release, since every stable install with CIMD enabled (the default) rejects all private_key_jwt clients.

Metadata

Metadata

Assignees

No one assigned

    Labels

    authRelated to authentication (Bearer, JWT, OAuth, WorkOS) for client or server.bugSomething isn't working. Reports of errors, unexpected behavior, or broken functionality.high-priorityv3Targeted for FastMCP 3

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions