Summary
The MCP TypeScript SDK's OAuth client flow trusted the MCP server too far: the server being authenticated to could influence which authorization server the client authenticated at. That inverts the trust relationship an OAuth client depends on, and it means a hostile MCP server can collect credentials that were never meant for it. This repository pinned the affected @modelcontextprotocol/sdk@1.30.0; the dependency now resolves to ^1.31.0, which carries the upstream fix for CVE-2026-104850.
Introduction
In the Model Context Protocol, an MCP client connecting over HTTP that receives a 401 is expected to bootstrap OAuth from the server's own response: read the WWW-Authenticate challenge, fetch protected resource metadata, learn which authorization server guards that resource, then register a client (often dynamically) and run an authorization-code exchange. The SDK packages all of that behind auth() and the OAuthClientProvider interface so that application authors never hand-roll the dance.
The problem is that every input to that discovery chain originates with the MCP server — the very party whose honesty is in question. CVE-2026-104850 describes exactly that failure mode: the OAuth client "could send credentials to an authorization server chosen by the MCP server." If the resource metadata or the advertised authorization_servers entry is not bound back to the MCP server's own origin and an operator-supplied allowlist, then the endpoint receiving your client_id, client_secret, and authorization code is picked by whoever wrote the MCP server manifest.
This matters well beyond MCP. Any client that performs discovery-driven OAuth — OpenID Connect issuer discovery, RFC 9728 protected resource metadata, .well-known lookups keyed off a redirect or a header — has the same shape of bug waiting in it. Discovery tells you where to go; it must never be the thing that decides whom to trust.
Affected Versions
| Affected | unknown lower bound, through 1.30.0 (the version pinned in this project) |
| Fixed in | 1.31.0 |
| Ecosystem | npm |
| CVE / GHSA | CVE-2026-104850 / not assigned |
| CWE | unknown |
Upstream did not publish a precise affected range alongside the identifier, so treat anything below 1.31.0 as suspect rather than assuming a narrow window.
The Vulnerability Explained
The change in this project is a dependency bump, so the vulnerable logic lives in the SDK rather than in first-party code. What the lockfile records is the exposure:
- "@modelcontextprotocol/sdk": "1.30.0",
+ "@modelcontextprotocol/sdk": "^1.31.0",
That one line is the whole finding: the build was pinned to an exact version on the vulnerable side of the fix.
The trust inversion
Walk the SDK's HTTP OAuth path as an attacker. You publish an MCP server at https://tools.attacker.example and get a developer — or an agent runtime acting on a user's behalf — to add it as a tool provider.
- The client calls your
/mcpendpoint throughStreamableHTTPClientTransport. You answer401with aWWW-Authenticatechallenge that includes aresource_metadatapointer. - The SDK's
auth()flow follows that pointer and reads protected resource metadata. You control the document, so you declare anauthorization_serversentry ofhttps://as.attacker.example. - The client fetches authorization server metadata from your endpoint and performs dynamic client registration against it. Your server now holds a
client_idand, for confidential clients, aclient_secret— issued by the client's own registration request, handed straight to you. - The browser is redirected to your
authorization_endpoint. Depending on the client's UX, the user sees a login page that looks like the service they expected to authorize. - The client exchanges the returned code at your
token_endpoint, sending its registered credentials and PKCE verifier along with it.
Nothing in that sequence requires breaking cryptography or guessing a secret. The attacker simply answers a question the client should never have asked a stranger: which authorization server should I trust for you?
Why PKCE and state do not save you here
It is tempting to assume PKCE blunts this. It does not. PKCE protects an authorization code from interception by a third party between a legitimate AS and the client. Here the attacker is the authorization server, so it receives the verifier itself. Likewise, state validation only proves the response came back to the same client session that started it — and it did. The controls that would have mattered are origin binding and an allowlist, neither of which is a cryptographic primitive.
Real-world impact
For a service embedding this SDK, the concrete losses are:
- Dynamically registered client credentials disclosed to an attacker-run endpoint, usable to impersonate your client against any AS that honors the same registration.
- Phishing with a legitimate-looking consent step. Because the client itself drove the redirect, the user has no obvious cue that the login screen is not the real identity provider.
- Tokens minted by the wrong issuer. If downstream code accepts an access token without validating the issuer and audience against the resource it was requested for, the attacker's token can be replayed into your own request path.
- Lateral movement in agent runtimes, where one agent may be connected to a dozen MCP servers and a single hostile entry sees the credential bootstrap for the connections it can influence.
The Fix
The remediation in this repository is the version move. The dependency declaration went from an exact pin on the vulnerable release to a caret range on the fixed one, and the lockfile was regenerated to resolve 1.31.0:
- "@modelcontextprotocol/sdk": "1.30.0",
+ "@modelcontextprotocol/sdk": "^1.31.0",
Two things are worth calling out about this diff.
The range widened deliberately. Moving from 1.30.0 to ^1.31.0 means future 1.x releases install without a second lockfile edit. For a package shipping auth-flow security fixes, that is the behaviour you want; reproducibility is still guaranteed by the single resolved version recorded in the lockfile.
Most of the diff is npm metadata churn, not substance. Regenerating the lockfile with a different npm version dropped libc constraint arrays from a batch of optional, platform-specific dev dependencies. Those entries are build-tooling binaries and are unrelated to the vulnerability. Reviewers should not let that noise obscure the one line that matters — and should not treat those removals as part of the security change.
Upstream behaviour change. On the SDK side, 1.31.0 constrains which authorization server the OAuth flow will accept for a given MCP server, so a server can no longer nominate an arbitrary issuer and receive the client's credential bootstrap. The public surface — auth(), OAuthClientProvider, the HTTP transports — is unchanged, so this is a drop-in upgrade for application code.
Verify the whole tree, not just the top level. A direct bump does not guarantee a single copy. Confirm with:
npm ls @modelcontextprotocol/sdk
If a transitive dependency carries its own pinned 1.30.x, resolve it with an override or by upgrading that intermediate package.
Note that this change was not validated by an automated check against the project, so exercise your MCP client's OAuth path — a real 401 bootstrap against a server you control — before shipping.
Key Takeaways
- An MCP server must never be the source of truth for which authorization server its client trusts. Versions of
@modelcontextprotocol/sdkbefore1.31.0let server-supplied discovery data decide where credentials went;1.31.0binds that choice. - PKCE and
statedo not mitigate a malicious authorization server. When the attacker is the AS, it receives the code verifier and the state round-trips correctly. Only origin binding and an issuer allowlist help. - Dynamic client registration is a credential-emitting operation. Pointing it at an unvetted endpoint hands out a
client_id/client_secretpair; treat the registration endpoint with the same suspicion as a token endpoint. - An exact pin is not a security posture. This project was pinned to
1.30.0, which froze it on the vulnerable side of CVE-2026-104850 until a human intervened. Pin for reproducibility, but pair it with advisory monitoring. - Separate the signal from lockfile noise. The
libcarray removals in this diff are npm regeneration artifacts; the security-relevant change is a single version string.
How Orbis AppSec Detected This
- Source: MCP server-controlled OAuth discovery data — the
WWW-Authenticatechallenge returned by the MCP endpoint and the protected resource metadata it points to, including the advertisedauthorization_serversvalue, as consumed by the SDK'sauth()flow. - Sink: the SDK's OAuth client requests to that nominated authorization server — dynamic client registration and the authorization-code/token exchange, which carry the client's
client_id,client_secret, and PKCE verifier. - Missing control: no binding of the discovered authorization server back to the MCP server's own origin, and no operator-supplied allowlist of acceptable issuers, before credentials were transmitted.
- CWE: unknown — no CWE has been assigned to CVE-2026-104850 at the time of writing.
- Fix: upgrade
@modelcontextprotocol/sdkfrom the pinned1.30.0to^1.31.0, which constrains the authorization server the OAuth client will trust for a given MCP server.
Detection here was dependency-graph based: the vulnerable @modelcontextprotocol/sdk@1.30.0 was present in the resolved tree and matched the advisory for CVE-2026-104850. Reachability into the OAuth code path was not verified, so confirm whether your application actually invokes the HTTP OAuth flow when prioritising.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
CVE-2026-104850 is a textbook trust-direction bug dressed in modern plum