All posts
Supply Chain Compromise

MCP TypeScript SDK: NHIs With No Home Authorization Server

CVE-2026-104850 lets a hostile MCP server redirect an OAuth client's stored refresh token and client secret to an attacker-run authorization server

Securityv0 Intelligence Team OWASP: ASI06 sv0 finding: nhi_compromise
mcp oauth nhi typescript-sdk cve-2026-104850 model-context-protocol

The Incident

On September 30, 2026, the Model Context Protocol project disclosed GHSA-6qxp-vccf-f47h in the MCP TypeScript SDK’s OAuth client. NVD published the record as CVE-2026-104850 on October 6, 2026, at CVSS v3.1 7.5 (vector AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N), with CWE-345 (Insufficient Verification of Data Authenticity) and CWE-522 (Insufficiently Protected Credentials). The flaw is in @modelcontextprotocol/sdk 1.12.0 through 1.30.1 and @modelcontextprotocol/client 2.0.0 through 2.1.0. Patches shipped as @modelcontextprotocol/sdk 1.31.0 and @modelcontextprotocol/client 2.2.0.

The failure mode is a single sentence of the advisory: the SDK’s OAuth client let the connected MCP server decide which authorization server received the client’s OAuth credentials, and stored credentials were not tied to the server that issued them. A hostile or compromised MCP server could advertise its own authorization server in its metadata and, with no user interaction, cause the client to forward a previously stored refresh_token and client_secret from an earlier interactive sign-in, or the configured client_secret or signed JWT assertion of a bundled non-interactive provider — ClientCredentialsProvider, PrivateKeyJwtProvider, StaticPrivateKeyJwtProvider, or CrossAppAccessProvider. Per the advisory, MCP servers built with the SDK and stdio clients are not in scope; the exposure is specific to apps using the SDK as an HTTP MCP client with an auth provider connecting to servers they do not fully trust.

The fix persists an issuer field on saveTokens() and saveClientInformation(), treats mismatched stored values as absent (forcing a fresh authentication flow), and deprecates constructing bundled providers without an expectedIssuer. The 2.0.0 and 2.1.0 releases already accepted the expectedIssuer option; 1.31.0 ports the per-authorization-server binding to the 1.x line. The advisory also notes gaps that the patch does not close: new interactive sign-ins, direct refreshAuthorization() or exchangeAuthorization() calls, and 2.x clients still passing skipIssuerMetadataValidation: true. The advisory’s workaround for 1.x operators who cannot upgrade is to connect OAuth-enabled clients only to MCP servers they trust.

The Authority Path That Failed

The identity carrying authority is the OAuth-enabled MCP client application, holding non-human credentials on behalf of one specific home authorization server. Its held scope is “authenticate to the authorization server that minted these credentials.” The exercised scope, pre-patch, was wider: “authenticate to whichever authorization server the current MCP server advertises in its OAuth metadata.” The trust anchor that failed first was issuer binding on the client side. Nothing persisted the issuer alongside the credential, so on every call the client re-derived the target server from protocol-level metadata supplied by the server it was talking to, and treated that server’s claim as authoritative.

That gap converts a server-side trust decision into theft of the credential itself. A rogue MCP server does not need to compromise the real authorization server, intercept traffic, or trick a human — it only needs the client to connect once and advertise an attacker-run OAuth metadata URL. The client then hands over a long-lived refresh token or a signing-key-equivalent assertion to a party that was never meant to see either. The 1.31.0 and 2.2.0 fixes close the gap by making issuer identity part of the credential’s stored shape and by making expectedIssuer a required construction parameter for bundled providers.

SecurityV0 Perspective

The control question is straightforward: can the organization prove that every OAuth credential held by an AI client is bound to a known issuer — and prevent it from being reused somewhere else? The evidence needed to answer it is equally concrete: the MCP OAuth clients in use and their SDK versions; the issuer recorded for each stored token, secret, and assertion; the provider configurations missing expectedIssuer; every client that opts out through skipIssuerMetadataValidation: true; and the authorization-server metadata advertised by each connected MCP server.

That evidence changes remediation from a package upgrade into an authority decision. Before exposure, it identifies credentials that have no home issuer and should not be dispatchable. After exposure, it can show which credential was sent to which non-home issuer, on which call. SecurityV0’s nhi_compromise finding maps to this failure because every unit of authority at stake — refresh token, client secret, signed assertion — is a non-human identity.

What To Do

  • Upgrade @modelcontextprotocol/sdk to 1.31.0 and @modelcontextprotocol/client to 2.2.0. The 1.31.0 release adds the issuer field to OAuthTokensSchema and OAuthClientInformationSchema and rejects stored credentials whose issuer no longer matches the server being contacted; 2.2.0 extends the per-authorization-server binding already present in 2.0.0 and 2.1.0. If your app imports @modelcontextprotocol/core directly, bump that to 2.2.0 as well.
  • Pass expectedIssuer to every bundled non-interactive provider. ClientCredentialsProvider, PrivateKeyJwtProvider, StaticPrivateKeyJwtProvider, and CrossAppAccessProvider all accept expectedIssuer after the patch; constructing without it is deprecated and logs a console warning, while an empty or null value throws. Treat the throw as the right default for CI — a missing expectedIssuer means the credential is not bound to a specific tenant and must not ship.
  • Audit 2.x configurations for skipIssuerMetadataValidation: true. The advisory calls out that flag as a configuration that opts out of the fix. Grep every MCP client config repo for the string, require a documented reason for each hit, and set a lint rule that blocks new occurrences.
  • Rotate credentials for any OAuth client that connected to an untrusted MCP server on an affected version. The advisory’s remediation path after possible exposure is to rotate the client secret or signing key and revoke outstanding tokens. Inventory connections by MCP server URL and connection timestamp against the 1.12.0–1.30.1 and 2.0.0–2.1.0 affected window, and rotate on every match rather than every suspected exposure.
  • Make issuer binding a build-time invariant for stored OAuth credentials. The structural lesson of CVE-2026-104850 is that any credential store that does not record the issuer alongside the credential is one metadata call away from this bug. Require every credential record in your MCP client — stored tokens, stored client information, bundled provider configs — to carry an issuer field, and gate dispatch on an exact-match check against the server currently being contacted. The fix in @modelcontextprotocol/sdk is the pattern; apply it to any OAuth NHI store you own.

Sources