Repository navigation
MCP mTLS transport may inject ADC Authorization when an existing auth header is lowercase #6200
Description
🔴 Required Information
Describe the Bug:
In the MCP mTLS transport path, ADK may inject ADC / execution-environment Service Account credentials even when an Authorization header is already present on the MCP request.
The immediate cause is that _RefreshableAsyncCredentials.before_request() checks for an existing Authorization header case-sensitively:
if 'Authorization' in headers:However, in the mTLS path, the MCP SDK uses httpx.AsyncClient, and ADK's _GoogleAuthAsyncTransport.handle_async_request() builds a plain dict from httpx.Request.headers before passing it to AsyncAuthorizedSession.request():
headers_dict = dict(request.headers)httpx lowercases header names internally, so an existing Authorization header may be passed as authorization. In that case, the case-sensitive check misses the existing header and ADC credentials may be refreshed and injected into the same request.
As a result, an MCP server can receive an opaque access token for the runtime Service Account instead of the intended user / 3LO token.
Steps to Reproduce:
- Use ADK v2.3.0 or current
main. - Configure
McpToolsetwithStreamableHTTPConnectionParamsorSseConnectionParams. - Ensure the MCP request already has a user / 3LO
Authorizationheader, for example through MCP auth credential handling orheader_provider. - Run an MCP tool call in an environment where the mTLS transport path is enabled:
GOOGLE_API_USE_CLIENT_CERTIFICATEis unset or set totruegoogle.auth.aiois available- ADC is available
- ADK can create
_GoogleAuthAsyncTransportin_get_mtls_transport()
- In
_GoogleAuthAsyncTransport.handle_async_request(),dict(request.headers)can produce a lowercaseauthorizationkey. _RefreshableAsyncCredentials.before_request()checks'Authorization' in headersand misses the lowercase key.
Expected Behavior:
HTTP header names are case-insensitive. _RefreshableAsyncCredentials.before_request() should treat Authorization, authorization, and any other casing as the same header.
If any Authorization header is already present, ADK should not inject ADC / Service Account credentials.
Observed Behavior:
An existing Authorization header can be missed when its key is lowercase. In that case, ADK may refresh ADC credentials and inject an execution-environment token into the MCP request.
This can cause the MCP server to receive a Service Account / ADC token instead of the intended user / 3LO token.
Environment Details:
- ADK Library Version (pip show google-adk): v2.3.0 and current
main - Desktop OS: Linux
- Python Version (python -V): Python 3.11
Model Information:
- Are you using LiteLLM: N/A
- Which model is being used: N/A. This issue occurs in the MCP HTTP transport path before model behavior is relevant.
🟡 Optional Information
Regression:
This appears related to the mTLS transport work introduced in v2.3.0. The issue is not about list_tools / discovery auth propagation. It affects the MCP HTTP request path when the mTLS transport bridges httpx requests into google-auth-aio AsyncAuthorizedSession.
Logs:
N/A. The behavior can be demonstrated at the header-normalization level:
dict(httpx.Request(..., headers={"Authorization": "Bearer user_token"}).headers)
contains authorization, not Authorization.
Screenshots / Video:
N/A.
Additional Context:
The existing Authorization header is not only a user-supplied static header. ADK has MCP header propagation paths intended to pass user / 3LO credentials to the MCP server during MCP tool execution.
After a 3LO flow completes, ADK resolves the user access token as an AuthCredential. In the MCP integration, that credential is converted into an HTTP header by McpToolset / McpTool. For OAuth2 credentials, the implementation generates a header like this:
headers = {"Authorization": f"Bearer {credential.oauth2.access_token}"}For tool execution, the flow is roughly:
McpTool._run_async_impl()extracts auth headers from the resolved credential.- If
header_provideris configured, it also obtains dynamic headers fromReadonlyContext. - The auth headers and dynamic headers are merged.
- The merged headers are passed to
MCPSessionManager.create_session(headers=final_headers). MCPSessionManagerpasses those headers to the MCP SDK'sstreamablehttp_client/sse_client.
The discovery path (McpToolset.get_tools() / list_tools) has a similar flow through McpToolset._execute_with_session(), where headers from header_provider and exchanged credentials are merged and passed to create_session(headers=...).
Therefore, after a successful 3LO flow, the MCP HTTP request is expected to already contain:
Authorization: Bearer <user_3lo_access_token>That header is required by the MCP server to identify the user context. If the later mTLS transport / Google auth bridge injects ADC or Service Account credentials into the same request, the principal observed by the MCP server can change from the user to the runtime Service Account.
This issue is not about failing to obtain the user token, nor is it about the MCP header propagation path failing to set a header. The problem occurs after the user / 3LO Authorization header has already reached create_session(headers=...): in the mTLS transport path, dict(request.headers) can represent it as lowercase authorization, and _RefreshableAsyncCredentials.before_request() does a case-sensitive check for Authorization.
This bug report focuses especially on the MCP tool-call request path. Independently of list_tools / discovery behavior, the tool-call HTTP request path should preserve and respect an existing Authorization header case-insensitively.
Suggested fix:
if any(key.lower() == "authorization" for key in headers):
logger.debug("Authorization header already present, not overwriting")
returnHowever, the header handling itself should still be fixed because HTTP header names are case-insensitive.
Related issues / PRs:
- mcp_toolset is incompatible with mTLS #5361: MCP mTLS support discussion / predecessor context
- chore(release/candidate): release 2.3.0 #6150: v2.3.0 release including the mTLS transport work
- feat(mcp): Add session state-based JWT token propagation for MCP tools #3675: MCP header propagation feature, related but not the same bug
- fix(api_registry): only attach ADC credentials to Google API hosts #6146: ADC credentials being attached in another component, related security theme but different code path
Minimal Reproduction Code:
import httpx
request = httpx.Request(
"POST",
"https://example.com/mcp",
headers={"Authorization": "Bearer user_token"},
)
print(dict(request.headers))
# contains "authorization", not "Authorization"How often has this issue occurred?:
- Always (100%) when the mTLS transport path is active and the existing Authorization header is represented as lowercase
authorization.
Activity
fix(mcp): Preserve existing auth in mTLS transport
sanketpatil06 commentedon Jun 24, 2026
Hi @h-tsuboi918 , Thank you for reporting this issue and submitting the fix in PR #6201.
The issue happens because google-adk looks specifically for a capitalized "Authorization" header. Since many HTTP clients use lowercase "authorization", the library misses your existing token and incorrectly adds its own Google Application Default Credentials (ADC).
Workaround: Until PR #6201 is merged and released, you can avoid this issue by ensuring any user-supplied authentication headers are explicitly passed using the capitalized
"Authorization"key to satisfy the current check:h-tsuboi918 commentedon Jun 24, 2026
Hi @sanketpatil06 , Thanks for the suggestion. I tested the suggested workaround against current
mainusing the sameMcpToolsetconfiguration shown in the comment:To make the behavior observable without depending on real ADC or a real remote MCP server, I used:
Authorizationheaderrefresh()setsservice_account_tokenMcpToolset.get_tools(),MCPSessionManager.create_session(), MCP SDKstreamablehttp_client,httpx.AsyncClient,_GoogleAuthAsyncTransport, and the current_RefreshableAsyncCredentials.before_request()Here is the minimal repro I used:
Output on current
main:So even with exact
Authorizationcapitalization inStreamableHTTPConnectionParams.headers, the mTLS/httpx path still causes_RefreshableAsyncCredentials.before_request()to miss the existing user token and inject ADC credentials.This workaround may be useful outside the mTLS/httpx path, but it does not appear to avoid the issue described here.
This is particularly problematic for 3LO flows. In that case, the user access token may have been obtained successfully and passed into the MCP request as
Authorization: Bearer <user_token>, but the mTLS/httpx bridge can still cause ADK to inject the runtime ADC / Service Account token instead. From the MCP server's perspective, the request can then appear to come from the execution environment rather than the end user, which breaks user-scoped authorization and auditing.fix: in mcp mtls logic, check for authorization header case insensiti…
fix: in mcp mtls logic, check for authorization header case insensiti…