When your Model Context Protocol (MCP) client connects to a new tool server, it often starts an OAuth handshake before exchanging any tool definitions. To do that without hardcoded credentials, modern MCP implementations rely on dynamic discovery.

Before your client opens a login page, it fetches a small JSON file. That file tells the client which authorisation server to trust, which token endpoints to query and which signing keys to verify. The trouble is that the remote MCP server chooses that URL. If an attacker controls the server, they control where your client sends its next HTTP request.

If the client fetches that URL without strict network controls, an attacker can point it straight at your local machine, your internal Kubernetes network or your cloud metadata service. That is how automated OAuth discovery turns into Server-Side Request Forgery (SSRF).

Where the discovery URL comes from

Under RFC 9728 and RFC 8414, an OAuth-enabled MCP server does not expect you to pre-configure every endpoint by hand. Instead, it guides the client through automated discovery hops.

First, the client sends an initial unauthenticated request to the remote MCP server. The server rejects it with an HTTP 401 Unauthorized response. Inside the WWW-Authenticate header, the server passes a parameter called resource_metadata:

HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer resource_metadata="https://untrusted-server.example/.well-known/oauth-protected-resource" 

Second, the client extracts that URL and sends a GET request to read the Protected Resource Metadata (PRM) document. This document tells the client which authorisation servers issue valid tokens:

{ "resource": "https://untrusted-server.example/mcp", "authorization_servers": ["https://auth.example.com"] } 

Third, the client contacts the discovered authorisation server at its /.well-known/oauth-authorization-server path. That response specifies endpoints for token exchange and dynamic client registration.

Every step in this chain relies on dereferencing URLs provided by an external system. If your application fetches them naively, the remote server does not have to point to a valid identity provider. It can supply http://169.254.169.254/latest/meta-data/ or http://127.0.0.1:6379/ instead.

A real vulnerability in the wild

This is not a theoretical design risk. In September 2026, researchers disclosed CVE-2026-51994 in mcp-remote, an open-source bridge used to connect developer tools to remote MCP servers over HTTP, documented in the NVD entry.

Affected versions (0.1.32 through 0.1.38) parsed the resource_metadata parameter directly from the WWW-Authenticate header, as analysed in the security advisory. The library immediately issued an HTTP GET request to whatever URL the server returned. Because there were no checks on schemes, private IP ranges or origins, a malicious MCP server could force a developer's workstation or hosted runner to probe local ports, scan private subnets or harvest cloud credentials.

A similar issue affects servers when implementing Client ID Metadata Documents, specified in the CIMD draft. In that setup, untrusted clients supply a URL as their client_id. If the server-side authorisation gateway fetches that document to read client names and logos without egress controls, the direction reverses: a malicious client attacks the internal server backend.

Why standard fetch calls fall short

Most developers write discovery code using standard HTTP clients like Axios or standard fetch(). The naive implementation usually looks like this:

import axios from 'axios'; async function getProtectedResourceMetadata(serverUrl: string) { const res = await axios.get(serverUrl, { validateStatus: () => true }); if (res.status === 401) { const header = res.headers['www-authenticate']; const match = header?.match(/resource_metadata="([^"]+)"/); if (match?.[1]) { return (await axios.get(match[1])).data; } } return null; } 

Passing match[1] directly to axios.get() fails three fundamental security checks:

  1. It allows insecure schemes such as plain http:// or internal protocols.
  2. It ignores the server origin, letting an untrusted host point to internal addresses.
  3. It follows HTTP redirects automatically, meaning an initially valid domain can redirect the client to http://169.254.169.254/.

To see how these request flaws behave across different web frameworks, check out the CodeReviewLab SSRF guide.

Validating discovery safely

Fixing discovery SSRF requires validating the URL before connecting, checking the resolved IP address and ensuring the connection actually goes to that verified IP.

Checking the hostname alone is not enough. An attacker can set up a public domain with a very low TTL (Time to Live) that points to an external IP during your check, then returns 127.0.0.1 when your HTTP client connects. You can build hands-on instincts for spotting these request flaws across realistic codebases on CodeReviewLab.

In modern Node.js, the global fetch() implementation is powered by Undici. It silently ignores options like { agent: new https.Agent(...) }. If you pass an old Node HTTPS agent to global fetch(), socket pinning never happens and DNS rebinding remains open.

Additionally, using a simple denylist with ipaddr.parse() can allow bypasses via IPv4-mapped IPv6 addresses (such as ::ffff:127.0.0.1), which return 'ipv4Mapped' instead of 'loopback'. Using ipaddr.process() normalises these addresses, and checking that the range is strictly 'unicast' blocks internal subnets, loopbacks and special allocations.

Origin matching must also respect the protocol stage. In Hop 2, RFC 9728 requires the resource_metadata document to match the MCP server's origin. In Hop 3, however, the client contacts external identity providers (such as https://auth.example.com). Restricting Hop 3 to the tool server's origin would break legitimate identity delegation. Hop 3 must permit cross-origin destinations while strictly maintaining socket-level IP and scheme restrictions.

Here is an illustrative helper demonstrating origin checking and socket-level DNS pinning in Node.js using Undici. Notice that Undici's connect.lookup supports the opts?.all array contract used by Node's default autoSelectFamily mechanism:

import { Agent } from 'undici'; import * as dns from 'node:dns/promises'; import ipaddr from 'ipaddr.js'; export const safeDispatcher = new Agent({ connect: { lookup: async (hostname, opts, callback) => { try { const records = await dns.lookup(hostname, { all: true }); for (const r of records) { if (ipaddr.process(r.address).range() !== 'unicast') { return callback(new Error(`Non-public IP blocked: ${r.address}`)); } } callback(null, opts?.all ? records as any : records[0]?.address, records[0]?.family); } catch (err) { callback(err as Error); } } } }); export async function safeFetchMetadata(url: string, expectedOrigin?: string) { const target = new URL(url); if (target.protocol !== 'https:') throw new Error('HTTPS required'); if (expectedOrigin && target.origin !== expectedOrigin) { throw new Error('Cross-origin metadata rejected'); } return fetch(target.href, { dispatcher: safeDispatcher, redirect: 'error' }); } 

What this covers and what it leaves open

This safe fetch pattern blocks direct SSRF, prevents redirect hopping and stops DNS rebinding by pinning the socket to the checked IP. It also enforces HTTPS, which is required by OAuth 2.1 specifications.

However, application-level checks do not replace network-level controls. If your MCP client runs inside an AWS container, ensure the instance enforces IMDSv2 with a hop limit of 1 as outlined in cloud security guidance. Under IMDSv2, metadata retrieval requires an initial HTTP PUT request to /latest/api/token to acquire a session token. Setting the response hop limit to 1 ensures the token response is dropped across container virtual bridge interfaces, preventing containerised workloads from obtaining the token required for subsequent GET requests.

Keep one important caveat in mind: if a container runs with host networking (--net=host), it shares the host's network namespace (0 hops), meaning a hop limit of 1 does not protect it. In enterprise environments, combining container isolation with a dedicated egress proxy provides the strongest defense.

Review checklist for MCP integrations

When reviewing an MCP client, gateway or authorisation server, check these points:

  • Look for where WWW-Authenticate headers and resource_metadata parameters are parsed.
  • Ensure HTTP clients do not use default configurations that follow redirects automatically across origins.
  • Verify that IP filters use ipaddr.process() or equivalent normalisation rather than raw denylists or regexes, avoiding octal, hex and IPv4-mapped IPv6 evasions.
  • Ensure the socket connects directly to the IP validated during DNS resolution, rather than performing an unpinned lookup on connect.
  • If your service processes Client ID Metadata Documents, enforce identical egress validation on every client-supplied URL.

If you want to review the broader authentication flow and access token handling, read the CodeReviewLab OAuth 2.0 guide.

Have you audited the OAuth discovery flow in your MCP tooling yet? What guardrails do you use for outbound requests?