GoogleTokenVerifier sends the access token in the tokeninfo URL, so HTTP client INFO logs expose it
Summary
GoogleTokenVerifier.verify_token (fastmcp 4.0.4, fastmcp/server/auth/providers/google.py) validates a Google access token with:
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
- Token placed in the URL:
google.pyL116-L120 @ v4.0.4 - Same function sending the token as a header instead:
google.pyL182-L186 @ v4.0.4 - The request-line log, method plus full URL, at INFO:
httpx2/_client.pyL1086 @ v2.13.0 (async path at L1924), logger defined at L110
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.
Send the token as a bearer header, matching the userinfo call in the same function:
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.POST with the token in the form body (
client.post(url, data={"access_token": token})). Same effect; slightly further from the documented GET.Keep the documented query form and redact instead: install a
logging.Filteron thehttpx/httpx2loggers that replacesaccess_token=…(andid_token=…) values inHTTP 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 (
OAuthProxyviaGoogleProvider,GoogleTokenVerifier) - mcp 2.2.0 → httpx2 2.13.0
- Deployed on Cloud Run through googleads/google-ads-mcp v0.0.3
Source: PrefectHQ/fastmcp