#5145·fastmcp

GoogleTokenVerifier sends the access token in the tokeninfo URL, so HTTP client INFO logs expose it

Author: heymbCreated Sep 17, 2026Updated Sep 17, 2026
Labelsbugauthsecurityhigh-priority

Summary

GoogleTokenVerifier.verify_token (fastmcp 4.0.4, fastmcp/server/auth/providers/google.py) validates a Google access token with:

python
response = await client.get(
    "https://oauth2.googleapis.com/tokeninfo",
    params={"access_token": token},
    headers={"User-Agent": "FastMCP-Google-OAuth"},
)

The token travels in the URL. httpx2 (pulled in via mcp 2.x) logs every request line at INFO, method plus full URL, so any deployment that leaves that logger at INFO writes the user's Google access token to its logs on every authenticated request. With OAuthProxy + GoogleProvider this happens once per MCP call, since the upstream token is re-verified each time. On Cloud Run the line lands in Cloud Logging. The token is a live bearer credential for whatever scopes the user granted (in our case adwords).

Two lines later, the same function fetches userinfo with headers={"Authorization": f"Bearer {token}"}. The tokeninfo call is the only place in the verifier where the credential is in the URL.

Where it happens

Observed on a Cloud Run deployment of googleads/google-ads-mcp v0.0.3 (fastmcp 4.0.4): 24 such lines in three hours from a single user session, one per MCP call, each with a then-valid ya29.… token. Redacted sample:

2026-09-17T03:58:33Z  INFO:httpx2:HTTP Request: GET https://oauth2.googleapis.com/tokeninfo?access_token=[REDACTED] "HTTP/1.1 200 OK"

Why silencing the logger isn't enough

Downstream projects have tried to fix this by logger name: googleads/google-ads-mcp#91 set logging.getLogger("httpx") to WARNING. That stopped working when the client library became httpx2, and would break again on the next rename. The durable fix keeps the credential out of the request line, or makes sure it can't be logged.

Options

Google documents tokeninfo only as GET …?id_token= (for debugging), so every alternative below is observed behavior rather than documented contract. Verified against the live endpoint with a dummy token: each form returns invalid_token / Invalid Value (token parsed), while an empty request returns invalid_token / Either access_token, id_token, or token_handle required.

  1. Send the token as a bearer header, matching the userinfo call in the same function:

    python
    response = await client.get(
        "https://oauth2.googleapis.com/tokeninfo",
        headers={
            "Authorization": f"Bearer {token}",
            "User-Agent": "FastMCP-Google-OAuth",
        },
    )

    The logged line becomes GET https://oauth2.googleapis.com/tokeninfo "HTTP/1.1 200 OK". Headers are never in the request-line log.

  2. POST with the token in the form body (client.post(url, data={"access_token": token})). Same effect; slightly further from the documented GET.

  3. Keep the documented query form and redact instead: install a logging.Filter on the httpx/httpx2 loggers that replaces access_token=… (and id_token=…) values in HTTP Request: records. Preserves the exact documented call at the cost of touching third-party loggers.

I'd lean toward (1) for consistency with the existing userinfo call, but any of the three closes the leak regardless of how a deployment configures logging. Happy to open a PR for whichever you prefer.

Environment

  • fastmcp 4.0.4 (OAuthProxy via GoogleProvider, GoogleTokenVerifier)
  • mcp 2.2.0 → httpx2 2.13.0
  • Deployed on Cloud Run through googleads/google-ads-mcp v0.0.3