AI Engineering

MCP Security in 2026: The OAuth SDK CVEs, Tested, and How to Lock Down Your Server

Both official MCP SDKs had a high-severity OAuth flaw (CVE-2026-104850). I reproduced it locally, found that upgrading alone doesn't fix bundled providers, and show how a production MCP server handles OAuth, per-tool scopes and audit logging.

MCP security 2026 cover: attacker authorization server log receiving a refresh token, with SDK 1.30.1 leaked, 1.31 without issuer leaked and 1.31 with issuer blocked
On this page
  1. TL;DR: is MCP secure?
  2. What happened to MCP security in October 2026?
  3. I reproduced the MCP SDK OAuth flaw locally
  4. How do I fix the MCP SDK OAuth vulnerability?
  5. What does the MCP spec require for authorization?
  6. How does fabwaseem.com secure its own MCP server?
  7. Discovery and challenge
  8. Tokens bound to the resource
  9. SSRF-safe client metadata fetching
  10. Per-tool scopes, default deny
  11. Audit log and undo
  12. MCP security checklist
  13. Common MCP security mistakes
  14. Key takeaways

MCP security in October 2026 mostly means OAuth. Within two days of each other, both official MCP SDKs, TypeScript and Python, published high-severity advisories for the same flaw: a malicious MCP server could make your client send its OAuth credentials to an authorization server the attacker controls. The Rust SDK had fixed a related bug two weeks earlier, a LiteLLM MCP authentication bypass reached CISA's list of actively exploited vulnerabilities, and Ars Technica reported prompt-injection chains between agents connected over MCP.

This guide covers the real advisories, with links and affected versions. I also reproduced the TypeScript SDK flaw on my own machine to show what leaks, which versions stop it, and the one setting most people will miss after upgrading. I then walk through how this site's own MCP server at https://www.fabwaseem.com/api/mcp handles OAuth, scopes and audit logging, with measurements. Nothing here is hypothetical: every vulnerability has a public advisory, and every number comes from a run.

TL;DR: is MCP secure?#

The protocol's authorization spec is sound. The problems are in implementations: clients that trust whatever a server says, and servers that skip audience checks or expose too much. If you build an MCP client, upgrade and pass expectedIssuer. In my lab, @modelcontextprotocol/sdk 1.31.0 and 1.32.1 still sent a client secret to the attacker's authorization server when ClientCredentialsProvider was created without it. If you run an MCP server, validate the token's audience, scope every tool and deny unmapped tools by default, and keep an audit log.

AdvisoryComponentAffectedFixed inSeverity
CVE-2026-104850 / GHSA-6qxp-vccf-f47h@modelcontextprotocol/sdk and @modelcontextprotocol/client (TypeScript)1.12.0 to 1.30.1; client 2.0.0 to 2.1.x1.31.0; 2.2.0High, CVSS 7.5
GHSA-qx49-fqc8-xw99mcp (Python)1.9.1 to 1.29.x; 2.0.0a1 to 2.1.x1.30.0; 2.2.0High, CVSS 7.5
CVE-2026-63127 / GHSA-33f5-2c5q-wgwjrmcp (Rust)before 2.0.02.0.0High, CVSS 8.2
CVE-2026-59822 / GHSA-7488-6r32-c95qLiteLLM MCP endpointbefore 1.84.01.84.0High, on CISA KEV since Sep 2, 2026
CVE-2025-6514 / GHSA-6xpm-ggf7-wc3pmcp-remote0.0.5 to 0.1.150.1.16Critical, CVSS 9.6
CVE-2025-49596 / GHSA-7f8r-222p-6f5gMCP Inspectorbefore 0.14.10.14.1Critical

What happened to MCP security in October 2026?#

Four separate issues landed in about a month, and three share a root cause: one side trusted what the other side said about who it was.

  • Both official SDKs: issuer confusion. In MCP's OAuth flow, the client asks the MCP server where to sign in, using protected resource metadata. Affected SDK versions didn't tie stored credentials to the authorization server they came from. A malicious or compromised MCP server could name its own authorization server and receive the client's client_secret, signed client assertion or saved refresh_token, with no user interaction. The TypeScript advisory was published on September 30, 2026, and the Python one on September 28.
  • Rust SDK: missing resource check. rmcp before 2.0.0 didn't check that the resource in a server's metadata matched the server it connected to. A malicious server could steer the OAuth flow to a real authorization server and walk away with a token for the real resource.
  • LiteLLM: authentication bypass. The gateway's MCP endpoint let a request with a made-up Authorization header fall through to an OAuth2 passthrough path, which treated it as authenticated. CISA added it to the Known Exploited Vulnerabilities catalog on September 2, 2026.
  • Agent-to-agent prompt injection. Ars Technica reported research in which one compromised internal agent passed injected instructions to other agents that trusted it, leading in some cases to server-side request forgery (SSRF), for example through an HTTP client in Google's MCP Toolbox for Databases that followed redirects without checking where they went.

The 2025 bugs are still worth knowing. mcp-remote, a popular OAuth proxy, passed a server-supplied authorization URL to the system shell (CVE-2025-6514), and early MCP Inspector versions ran an unauthenticated proxy that could run commands (CVE-2025-49596). The pattern hasn't changed: anything an MCP server sends you is untrusted input, including metadata, URLs and tool output.

I reproduced the MCP SDK OAuth flaw locally#

I built a three-process lab on 127.0.0.1 with Node.js 24.22:

  • a legitimate authorization server on port 4401, the only one the client should ever send its secret to;
  • a malicious MCP server on port 4402, which answers every request with 401 and protected resource metadata naming the attacker's authorization server;
  • the attacker's authorization server on port 4403, which logs every request it receives.

The client used StreamableHTTPClientTransport with an auth provider in four configurations. In each one it connected to the malicious server while holding credentials meant only for the legitimate authorization server. I ran it against the last vulnerable release (1.30.1), the first fixed release (1.31.0) and the current release (1.32.1).

Client configuration1.30.11.31.01.32.1
ClientCredentialsProvider without expectedIssuersecret leakedsecret leaked (deprecation warning)secret leaked
ClientCredentialsProvider with expectedIssuersecret leakedblockedblocked
Stored refresh token saved without issuertoken + secret leakedtoken + secret leaked (warning)token + secret leaked
Stored refresh token saved with issuertoken + secret leakedblockedblocked

MCP OAuth issuer confusion: a malicious MCP server names an attacker authorization server; SDK 1.30.1 leaks the client secret and refresh token in every configuration, while 1.31.0+ blocks it only when expectedIssuer or issuer-bound storage is used

Here's what the attacker's authorization server received from the vulnerable client in the stored-token case. These are the lab's dummy values:

json
{"authorization":"Basic ZGVtby1jbGllbnQ6ZGVtby1zZWNyZXQtZm9yLUxFR0lULUFTLW9ubHk=",
 "body":{"grant_type":"refresh_token","refresh_token":"legit-refresh-token-123",
         "resource":"http://127.0.0.1:4402/mcp"}}

The Basic header decodes to demo-client:demo-secret-for-LEGIT-AS-only. That's the client secret and a refresh token for the legitimate server, handed to the attacker.

With the fix configured, the 1.31.0 client refused before sending anything:

text
Error: OAuth client information is bound to authorization server http://127.0.0.1:4401
and is not presented to http://127.0.0.1:4403.

In the issuer-bound stored-token case, the attacker's server only received a metadata GET and a dynamic client registration POST containing the public client name and redirect URI. It got no secret and no token.

Two findings matter in practice:

  1. Upgrading alone doesn't fix bundled providers. ClientCredentialsProvider, PrivateKeyJwtProvider, StaticPrivateKeyJwtProvider and CrossAppAccessProvider still follow the server's choice unless you pass expectedIssuer. 1.31.0 only prints a deprecation warning. The advisory says this explicitly, and my lab confirmed it on 1.32.1 too.
  2. Old saved tokens stay dangerous. Credentials saved before 1.31.0 carry no issuer, so the fixed SDK still uses them as-is. Clear them so users sign in again, or add issuer to them, and rotate any secret that may have reached an untrusted server.

How do I fix the MCP SDK OAuth vulnerability?#

For TypeScript clients:

bash
npm i @modelcontextprotocol/sdk@^1.31.0    # 1.x
npm i @modelcontextprotocol/client@^2.2.0  # 2.x
typescript
import { ClientCredentialsProvider } from '@modelcontextprotocol/sdk/client/auth-extensions.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

const authProvider = new ClientCredentialsProvider({
  clientId: process.env.MCP_CLIENT_ID!,
  clientSecret: process.env.MCP_CLIENT_SECRET!,
  expectedIssuer: 'https://auth.example.com', // the only AS allowed to see these credentials
});

const transport = new StreamableHTTPClientTransport(new URL('https://mcp.example.com/mcp'), { authProvider });

If you wrote your own OAuthClientProvider, store exactly what saveTokens() and saveClientInformation() receive, including the new issuer field. Return issuer from clientInformation() for pre-registered clients too. For Python, upgrade to mcp 1.30.0 or 2.2.0, and pass an explicit issuer= for machine-to-machine providers, as the WorkOS write-up of the three bugs explains.

Then:

  • clear credentials saved before the upgrade, or add issuer to them;
  • rotate client secrets and signing keys, and revoke tokens, for any client that may have connected to an untrusted server;
  • remember that a new interactive sign-in still goes to whichever authorization server the MCP server names. Only complete sign-ins for servers you trust.

What does the MCP spec require for authorization?#

The 2026-07-28 MCP authorization spec already defends against these bugs. Implementations just have to follow it:

  • Resource indicators (RFC 8707). Clients MUST send resource in both the authorization and token requests, set to the MCP server's canonical URI.
  • Audience validation. Servers MUST verify that a token was issued for them and reject everything else with 401. Clients MUST NOT send a server tokens issued for something else, which rules out token passthrough.
  • Issuer recording (RFC 9207). Before redirecting, the client MUST record the issuer from the validated authorization server metadata and check the iss returned on the redirect. Authorization servers SHOULD send iss and advertise authorization_response_iss_parameter_supported.
  • PKCE with S256, and protected resource metadata (RFC 9728) for discovery.

The companion security best practices page covers the confused deputy problem, token passthrough, SSRF (including SSRF against authorization servers), session hijacking, local server compromise, authorization URL validation, mix-up attacks, localhost redirect impersonation, client ID metadata document trust policies and scope minimization. The October SDK flaw is a mix-up attack. If you write MCP auth code, read that page end to end.

How does fabwaseem.com secure its own MCP server?#

This site runs a production MCP server for its CMS. It's how these posts get published. Here is what it does, verified against the live endpoint and the code. I've left out anything that isn't already public.

Discovery and challenge#

An unauthenticated tools/list returns 401 with a standard challenge pointing at protected resource metadata:

text
HTTP/2 401
www-authenticate: Bearer resource_metadata="https://www.fabwaseem.com/.well-known/oauth-protected-resource",
  error="invalid_token", scope="projects:read ... site:read offline_access",
  error_description="Invalid or missing bearer token"

The resource metadata names exactly one authorization server, the site itself, and the authorization server metadata at /.well-known/oauth-authorization-server advertises:

  • issuer: https://www.fabwaseem.com and authorization_response_iss_parameter_supported: true
  • code_challenge_methods_supported: ["S256"] only, so no plain PKCE
  • grant_types_supported: ["authorization_code", "refresh_token"] with public clients (token_endpoint_auth_methods_supported: ["none"]), so there's no client secret that could leak
  • dynamic client registration and client_id_metadata_document_supported: true

Tokens bound to the resource#

The authorization code, the code exchange and every refresh check that the requested resource canonically matches https://www.fabwaseem.com/api/mcp. A mismatch returns invalid_target. PKCE verifiers are compared in constant time. Codes live for 5 minutes and access tokens for 1 hour. Refresh tokens rotate on every use, and reusing an old refresh token revokes the whole token family. Tokens, codes and refresh tokens are stored only as peppered SHA-256 hashes.

SSRF-safe client metadata fetching#

Client ID metadata documents mean the server fetches a URL the client chooses. That's an SSRF risk, and the spec calls it out. The fetcher only accepts https: URLs with no credentials or fragments. It resolves DNS itself and rejects private and loopback addresses, then connects to the checked IP while keeping the original hostname for TLS, so DNS rebinding can't swap in an internal address after the check. It also caps the response at 64 KB and times out after 5 seconds. Redirect URIs must match exactly, and plain http: is only allowed for loopback hosts.

Per-tool scopes, default deny#

Every one of the 23 tools maps to one scope (posts:read, posts:write, posts:publish, history:undo and so on). A create_post or update_post call that would publish the post additionally requires posts:publish. The gate is three rules:

typescript
// A tool without a scope mapping needs full access, so a new tool is never
// callable by a scoped token by accident.
const required = scopesForToolCall(name, args) ?? [ADMIN_SCOPE]
if (scopeAllows(granted, required)) return null
return { id, required, message: `Insufficient scope: ${required.join(" ")} is required` }
  • Unmapped tools require admin. Adding a tool can't silently widen what an existing token can do.
  • Batches are all-or-nothing. Every message in a JSON-RPC batch is checked, and one denied call blocks the whole batch.
  • Some scopes are never implied. history:undo must be granted explicitly, even to an admin token. When I listed tools with this site's full-access token, the server returned 22 of 23, hiding undo_history.

tools/list is filtered too, so a scoped client only sees tools it can call. That's a security and a context-window win. Measured on the live server and counted with the o200k_base tokenizer:

Token scopesTools visibleTokens for tools/list
Full access227,778
posts:read4680
posts:read + site:read5783
Blog writer (read, write, taxonomy, media)104,308
Blog publisher (writer + posts:publish)114,554

A read-only agent loads 91% fewer tool-definition tokens and can't even see the write tools. For more on how much context MCP tool definitions cost, see MCP vs API.

How fabwaseem.com's MCP server checks a tools/call request: bearer token hash lookup, audience bound to the canonical resource, per-tool scope gate with default deny, audit event with actor and diff, and undo

Audit log and undo#

Every write, whether from MCP, REST or the admin UI, records an audit event with:

  • the actor type (OAuth client, API token or admin session) and the token or client name;
  • the source, the tool or route, and the action;
  • the entity and a field-level diff;
  • a request ID, a hashed IP and the user agent.

Raw IPs and token values are never stored, and the caller's scopes are used for the authorization check but never persisted. Events can be listed with history:read and reversed with history:undo. The 15 unit tests that cover the scope gate, PKCE, redirect matching, resource binding and private-address blocking all pass.

MCP security checklist#

If you build MCP clients:

  1. Upgrade to @modelcontextprotocol/sdk 1.31.0+, @modelcontextprotocol/client 2.2.0+, mcp (Python) 1.30.0+ or 2.2.0+, or rmcp 2.0.0+.
  2. Pass expectedIssuer (TypeScript) or issuer= (Python) to every machine-to-machine provider.
  3. Clear or migrate credentials saved before the upgrade. Rotate secrets that may have reached untrusted servers.
  4. Treat server metadata, authorization URLs and tool output as untrusted input. Never pass a server-supplied URL to a shell, which is the mcp-remote lesson.
  5. Ask the user before connecting to a new server, and before anything destructive.

If you run MCP servers:

  1. Validate the token audience against your canonical resource URI. Never forward client tokens to upstream APIs.
  2. Support PKCE S256 only. Send iss on redirects and advertise it.
  3. Give every tool a scope, deny unmapped tools, filter tools/list, and check every message in a batch.
  4. Prefer public clients with PKCE over client secrets you'd have to protect.
  5. Rotate refresh tokens, detect reuse, and store only hashes of tokens and codes.
  6. Fetch client metadata URLs with SSRF protections: an HTTPS allow-list, IP checks after DNS, pinned connections, size and time limits.
  7. Log every write with the actor, tool and diff, and make writes reversible.
  8. Rate-limit token and registration endpoints, and keep the MCP Inspector and other dev proxies off public networks.

Common MCP security mistakes#

  • Upgrading the SDK and stopping there. Bundled credential providers stayed vulnerable in my lab on 1.31.0 and 1.32.1 until I passed expectedIssuer.
  • Keeping pre-fix tokens. Tokens and client information saved without issuer are still sent wherever the server points.
  • Token passthrough. Accepting any bearer token, or forwarding it to another API, breaks the audience boundary the spec relies on.
  • Scopes on paper only. A read scope means nothing if tools/call doesn't check it, or if a new tool is open to every token by default.
  • Trusting tool output. Text returned by a tool can carry instructions. Agents that chain tools or other agents need their own guardrails, as the Ars Technica research showed.
  • Exposing dev tooling. An MCP Inspector or local proxy listening on a public interface is a remote shell waiting to happen.

Related: building your own server? Start with how to build an MCP server in Next.js and stateless MCP servers under the 2026-07-28 spec.

Key takeaways#

  • Both official MCP SDKs had a high-severity OAuth flaw (CVE-2026-104850 in TypeScript, GHSA-qx49-fqc8-xw99 in Python). A malicious MCP server could collect a client's secret or refresh token.
  • Fixed versions are TypeScript SDK 1.31.0 and client 2.2.0, Python mcp 1.30.0 and 2.2.0, and rmcp 2.0.0 for the related Rust bug.
  • In my local lab, 1.30.1 leaked credentials in all four configurations. 1.31.0 and 1.32.1 blocked the leak only with expectedIssuer or issuer-bound stored credentials.
  • Upgrading is not enough. Pass expectedIssuer, clear tokens saved before the fix, and rotate secrets that may have leaked.
  • The MCP spec already requires resource indicators, audience validation, PKCE and issuer checks. Most incidents come from skipping them.
  • On the server side, scope every tool, deny unmapped tools by default, filter tools/list and check batches. A read-only token on this site sees 4 tools and 680 tokens instead of 22 and 7,778.
  • Log every write with the actor, tool and diff, and make it undoable. Store only hashes of tokens and IPs.

Questions