Security Β· AI Agents Β· OAuth
πŸ”
MCP Python SDK Β· GHSA-qx49-fqc8-xw99
Authorization Server Discovery in Agent Clients
Issuer validation emblem: a cyan shield with a green check, an unverified iss struck through above a verified one

The Server Picks the Token Endpoint: Issuer Validation in Agent OAuth Clients

An MCP client connects to a server it has never met, asks that server where to log in, and then sends its secrets to the answer. On 28 September 2026 the official MCP Python SDK published an advisory showing what happens when that answer is not checked on every path. This is the architecture underneath it: how discovery chains trust from one document to the next, which link was missing, what an attacker walks away with, and why the fix is a string your client pins before it ever talks to the server.

ansi Β· wordmark Β· mcp oauth
β–ˆβ–ˆβ–ˆβ•—   β–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—
β–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—
β–ˆβ–ˆβ•”β–ˆβ–ˆβ–ˆβ–ˆβ•”β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘     β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•
β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘     β–ˆβ–ˆβ•”β•β•β•β•
β–ˆβ–ˆβ•‘ β•šβ•β• β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘
β•šβ•β•     β•šβ•β• β•šβ•β•β•β•β•β•β•šβ•β•

 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•—   β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•—  β–ˆβ–ˆβ•—
β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘   β–ˆβ–ˆβ•‘β•šβ•β•β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•‘  β–ˆβ–ˆβ•‘
β–ˆβ–ˆβ•‘   β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘   β–ˆβ–ˆβ•‘   β–ˆβ–ˆβ•‘   β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘
β–ˆβ–ˆβ•‘   β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘   β–ˆβ–ˆβ•‘   β–ˆβ–ˆβ•‘   β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘
β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘  β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•   β–ˆβ–ˆβ•‘   β–ˆβ–ˆβ•‘  β–ˆβ–ˆβ•‘
 β•šβ•β•β•β•β•β• β•šβ•β•  β•šβ•β• β•šβ•β•β•β•β•β•    β•šβ•β•   β•šβ•β•  β•šβ•β•

Overview

Picture a nightly job that runs an agent against a list of MCP servers. The job authenticates as a machine, not a person, so it carries a client_id and a client_secret that your platform team registered with your company's authorization server. Someone adds a new server URL to the list. It looks like an internal tool. The job connects, gets a 401, and starts OAuth discovery the way the SDK is built to. The new server says it has no protected resource metadata. The SDK falls back to asking the server itself for authorization server metadata. The server answers with a document whose issuer is your real identity provider, and whose token_endpoint is a host it controls. The job posts its client secret to that host. No browser opened. Nobody clicked anything. The secret is now reusable by someone else for as long as it stays valid.

That scenario is the one the advisory scores highest. GHSA-qx49-fqc8-xw99 on the mcp package gives it CVSS 7.5 with the vector AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N, and 6.5 where a user has to take part in the flow. The advisory's summary names two root causes: "the authorization server metadata issuer was not validated on every discovery path," and credentials were not bound to the authorization server they were meant for. Affected versions are 1.9.1 through 1.29.1 and 2.0.0a1 through 2.1.1. The fixes are 1.30.0 and 2.2.0.

The bug is specific. The lesson is not. Every agent that speaks MCP over HTTP runs the same conversation: a server you do not own tells your client where authorization happens. The fixed SDK's own deprecation warning puts the design problem in one sentence. If you leave out the issuer, "the MCP server decides which authorization server receives this client's credentials."

🧭

The Channel

Discovery metadata. The MCP server names its authorization server, and that server's metadata names the token endpoint. Each hop is a document someone else serves.

πŸ•³οΈ

The Gap

Issuer validation skipped on two paths in 2.x (the legacy fallback and the 403 step-up) and on every path in 1.x, per the reporter's write-up.

πŸ—οΈ

The Loot

Client secrets, authorization codes, PKCE verifiers and signed JWT client assertions, all sent to a token endpoint the attacker chose.

πŸ“Œ

The Fix

Validate issuer on every path, and let the client declare its expected issuer up front with issuer=, which becomes required in 3.0.

The Server Is Untrusted Input

Agent integration writing tends to treat the remote MCP server as a partner and OAuth as a solved library call. The protocol does not make that promise. RFC 9728, which MCP uses for the first discovery hop, says that "secure determination of appropriate authorization servers to use with a protected resource for all use cases is out of scope." Discovery tells you what a server claims. Only your client's configuration can tell you what you trust. Treat every discovery document the way you treat a request body from the internet: parse it, validate it against something you already know, and refuse to act on it otherwise.

The Discovery Chain

The current MCP specification is dated 2026-07-28. Its authorization section puts the roles plainly. The MCP server is an OAuth 2.1 resource server. The MCP client is an OAuth 2.1 client. The authorization server "may be hosted with the resource server or a separate entity." Everything the client knows about that authorization server, it learns at runtime from documents it fetches.

MCP CLIENT MCP SERVER AUTHORIZATION SERVER (your agent) (not yours) (maybe yours, maybe not) β”‚ β”‚ β”‚ 1 │── MCP request, no token ─▢│ β”‚ │◀─ 401 WWW-Authenticate: ─│ β”‚ β”‚ Bearer resource_metadata="https://mcp.x/.well-known/ β”‚ β”‚ oauth-protected-resource" β”‚ 2 │── GET protected resource β–Άβ”‚ β”‚ │◀─ { resource, authorization_servers: ["https://as.y"] } β”‚ β”‚ β”‚ β”‚ 3 │── GET https://as.y/.well-known/oauth-authorization-server β–Ά │◀─ { issuer, authorization_endpoint, token_endpoint, ... }─│ β”‚ β”‚ β”‚ CHECK: issuer == "https://as.y" (RFC 8414 3.3) β”‚ β”‚ RECORD: expected issuer next to PKCE verifier β”‚ β”‚ β”‚ 4 │── browser: authorize?client_id&code_challenge&resource ─▢│ │◀─ redirect: code + iss ───────────────────────────────────│ β”‚ CHECK: iss == recorded issuer (RFC 9207 2.4) β”‚ 5 │── POST token_endpoint: code, code_verifier, resource, ──▢│ β”‚ client authentication (secret or JWT assertion) β”‚ │◀─ access token (audience = mcp.x per RFC 8707) ──────────│ 6 │── MCP request + Bearer token ─▢│ β”‚ Every arrow pointing at the client is a document someone else controls. The two CHECK lines are the only places the chain is anchored.
01

401 and the resource metadata pointer

The server returns 401 with a resource_metadata URL in WWW-Authenticate. If the header has none, the spec says clients "MUST fall back" to well-known URIs, first at the path of the MCP endpoint, then at the root.

02

Protected resource metadata names the authorization server

The document "MUST include the authorization_servers field containing at least one authorization server." Choosing among several is the client's job. This hop is entirely the MCP server's claim.

03

Authorization server metadata names the endpoints

The client tries RFC 8414 and OpenID Connect well-known paths in a fixed order. Then the anchor: the issuer in the document "MUST be identical to the issuer identifier used to construct the well-known URL. If they differ, the client MUST NOT use the metadata."

04

Registration, PKCE and the recorded issuer

The client gets a client ID (pre-registration first, then Client ID Metadata Documents, then deprecated Dynamic Client Registration), generates PKCE with S256, and records the validated issuer "with the same per-request record used to store the PKCE code verifier."

05

Authorization response and token request

If the response carries iss, the client compares it to the recorded issuer by simple string comparison "before transmitting the authorization code to any token endpoint." Then it posts the code, the verifier and its client authentication to the token endpoint.

Read those five steps as a chain of custody. Step 2 is the MCP server's claim about who the authorization server is. Step 3 turns that claim into endpoints, and the issuer check is what stops a document served from one place from speaking for another. Step 5's iss check only means something if step 3 was validated. The spec says so directly: the response validation "provides no protection if the expected issuer was obtained from an unvalidated source." RFC 9207, which defines iss, makes the same point from the other side: clients relying on unvalidated metadata cannot get mix-up protection from it.

The Legacy Path Is Where Trust Went Missing

The 2025-03-26 MCP spec had no protected resource metadata at all. It assumed the MCP server could be its own authorization server, derived an "authorization base URL" by discarding the path from the server URL, fetched /.well-known/oauth-authorization-server from that origin, and fell back to default /authorize, /token and /register paths on a 404. Clients kept that fallback for older servers. So when a server says it has no resource metadata, a compatible client goes back to asking the server itself. Backward compatibility is a second door, and it needs the same lock as the first.

The Flaw, Path by Path

Cycode's write-up of the report shows the vulnerable condition in one line of the SDK's OAuth flow. Issuer validation only ran when the client already had an authorization server URL from protected resource metadata:

# vulnerable shape (as published in Cycode's write-up)
if self.context.auth_server_url is not None:
    validate_metadata_issuer(asm, self.context.auth_server_url)

# fixed shape: always compute an expected issuer, always check it
expected = self.context.auth_server_url or self.context.get_authorization_base_url(url)
validate_metadata_issuer(asm, expected)

On the fallback path, auth_server_url is None, so the check never runs. Whatever the server returned became the client's view of the authorization server. The fix computes an expected issuer on every path. With no resource metadata, that expected issuer is the MCP server's own origin, which is exactly what the legacy model said the authorization server would be. The 2.2.0 release notes state the result: "Authorization server metadata whose issuer isn't the server's own origin is now rejected with OAuthFlowError."

Path A: the legacy fallback (2.x and 1.x)

The attacker's server returns 404 for protected resource metadata. The client asks the server's origin for authorization server metadata. The server returns a document that lists the real provider's authorization_endpoint, the attacker's token_endpoint, and an issuer string matching the real provider. Before the fix, nothing compared that issuer to the origin it came from.

  • Interactive flow: the user signs in at the genuine provider, because the authorization endpoint is genuine. The code comes back to the client. The client redeems it at the attacker's token endpoint.
  • Machine flow: no user at all. The client posts its credentials straight to the attacker's token endpoint.

Path B: the 403 step-up (2.x)

The spec lets a server answer a valid token with 403, error="insufficient_scope", a scope list and a resource_metadata URI. Clients acting for a user "SHOULD attempt the step-up authorization flow." That re-authorization is a second discovery pass driven by the server, midway through a session the client already trusts. Cycode reports that 2.x skipped the issuer check on this path too.

  • Why it matters: a server can behave well at first connect and change its story on the first privileged tool call. Validation that only runs at connect time misses it.

Path C: 1.x, every path

Cycode's summary for 1.9.1 through 1.29.1 is "no issuer checks on any discovery path." The v2.0.0 release on 28 July 2026 had added "RFC 9207 issuer validation" alongside the SEP-990 identity assertion flow and the client credentials extension. The 1.x line never got the metadata check until 1.30.0.

Here is the forged document for Path A, reduced to the fields that matter. The client fetched it from https://tools.attacker.example/.well-known/oauth-authorization-server:

{
  "issuer": "https://login.yourcompany.example",
  "authorization_endpoint": "https://login.yourcompany.example/oauth2/authorize",
  "token_endpoint": "https://tools.attacker.example/oauth2/token",
  "code_challenge_methods_supported": ["S256"],
  "grant_types_supported": ["authorization_code", "client_credentials"]
}

# RFC 8414 3.3: issuer must equal the identifier used to build the URL.
# Built from https://tools.attacker.example, claims login.yourcompany.example.
# A validating client drops this document. A skipping client uses it.

RFC 8414 describes this exact impersonation in its security considerations: an attacker publishes metadata carrying a legitimate issuer identifier with endpoints of its own, and the defense is that the client checks that "the issuer identifier URL it is using as the prefix for the metadata request exactly matches the value of the 'issuer' metadata value." MCP's current spec gives nearly the same example in normative text, a document from attacker.example claiming honest.example that "MUST be rejected." The rule was written down in 2018. The bug was a code path that forgot to call it.

What Leaks, and How Long It Stays Useful

The advisory lists what an attacker receives: "client_secret values, authorization codes, PKCE verifiers, and signed JWT assertions." It names four affected classes: OAuthClientProvider, ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider and the deprecated 1.x RFC7523OAuthClientProvider. It also names what is not affected: MCP servers, stdio clients, and applications that manage their own tokens. Those items are not equal. Their value depends on how long they stay usable and whether the attacker can redeem them at the real authorization server.

Artifact Which flow sends it Useful to the attacker for What contains it
client_secret Client credentials, confidential authorization code clients Until rotated. Reusable to mint tokens as your client. Rotation at the authorization server
Authorization code Interactive OAuthClientProvider Single use, short lived, but redeemable once Code lifetime, one time use at the AS
PKCE code_verifier Interactive flow, sent with the code Paired with the stolen code, it defeats PKCE for that one exchange Nothing, once the code is also stolen
Signed JWT client assertion PrivateKeyJWTOAuthProvider, 1.x RFC7523OAuthClientProvider Until its exp, if its audience is accepted by the real AS Short exp, one time jti checks at the AS, the private key never leaves
Refresh tokens Any flow where the attacker completes the exchange Until revoked, unless the AS rotates them Revocation, rotation for public clients
PKCE Protects the Code From Everyone Except the Token Endpoint

PKCE exists so that an intercepted authorization code is useless without the verifier. It assumes the verifier only ever goes to the real token endpoint. When the client itself hands the code and the verifier to a token endpoint the attacker named, PKCE has nothing left to protect. Every client side OAuth defense assumes the endpoints came from validated metadata. Break that assumption and the other defenses pass their own checks while protecting nothing.

The JWT assertion row needs care. In the fixed SDK, the assertion's audience is set to the issuer identifier taken from the discovered metadata. In the forged document above, that issuer string is the real provider's. An assertion minted for that audience and sent to the attacker is, as far as its claims go, an assertion the real provider could accept until it expires. This is the class of problem the IETF OAuth working group is closing in draft-ietf-oauth-rfc7523bis, now in the RFC Editor queue at version 11 (26 March 2026). It requires that a client assertion's aud be the authorization server's issuer identifier "as its sole value," and it cites research showing that ambiguous audiences let assertions sent to one endpoint be reused at another. A strict audience helps. It does not help when the client was lied to about which issuer it is talking to. That is why the SDK now refuses to mint an assertion at all until metadata with the expected issuer has been found.

Cost Reality Check

No invoice, real hours: the MCP SDK is free and the fix is a version bump. The cost of this class of bug is credential rotation and investigation, and it scales with how many servers share one credential.

One shared secret: if every agent job authenticates to every MCP server with one registered client, one hostile server means rotating the secret everywhere it is deployed, and reading token endpoint logs for every server that ever received it.

One credential per connection: the same event is one rotation and one log review. The extra registrations cost minutes each at setup time.

What the Spec Already Defends

The 2026-07-28 spec release reads like a response to this whole class of problem. The MCP blog lists RFC 9207 issuer validation (SEP-2468), credential issuer binding (SEP-2352), application_type for Dynamic Client Registration (SEP-837), and the formal deprecation of Dynamic Client Registration in favor of Client ID Metadata Documents. Each defense covers a different attacker. None of them covers all of them.

Control Source Stops Does not stop
Metadata issuer must equal the URL it was fetched from RFC 8414 3.3, MCP AS discovery A document at one origin speaking for another A malicious server that honestly names its own malicious AS
iss in the authorization response RFC 9207, MCP authorization Mix-up between several ASes the client already validated Anything, if the recorded issuer came from unvalidated metadata
Credentials keyed by issuer MCP client registration, "Authorization Server Binding" Reusing a secret registered at AS 1 against AS 2 A server that points at your real AS's issuer with a forged token endpoint, unless metadata is validated
resource parameter and audience checks RFC 8707, MCP token handling A token for server A being replayed at server B Theft of client credentials before any token exists
PKCE with S256, refuse if unsupported OAuth 2.1, MCP security considerations Interception of the code on its way back A client that sends code and verifier together to the wrong endpoint
Pinned expected issuer in client config SDK issuer= (2.2.0, 1.30.0) The MCP server choosing where your pre-registered credentials go Nothing on this list, but only for credentials you configured

The binding rule deserves a close read, because it is the closest the spec gets to the fix. Clients using pre-registered credentials "MUST associate those credentials with the specific authorization server that issued them, keyed by the authorization server's issuer identifier." When the authorization server changes, clients "MUST NOT reuse client credentials from a different authorization server," and for pre-registered credentials they "SHOULD surface an error rather than silently attempting to use mismatched credentials." Keying by issuer is only as strong as the issuer check behind it. A forged document that claims the right issuer passes a key lookup and fails only an origin comparison.

Mix-Up Is an Old Attack With a New Entry Point

RFC 9700, the OAuth security best current practice from January 2025, describes the variant almost word for word: an attacker "might declare an uncompromised authorization server's authorization endpoint URL as 'their' authorization server URL, but declare a token endpoint under their own control." RFC 9207 adds that clients talking to only one authorization server are not exposed to mix-up, but become exposed when they add a second. An MCP client is a multi authorization server client by design. Every server you add can bring its own. That puts every agent host in the population the mix-up defenses were written for.

Pin the Issuer

The durable fix is a change in who decides. Discovery still runs, because you still need endpoints and capabilities. But for any credential you provisioned yourself, the expected issuer comes from your configuration, not from the server. The fixed SDK makes this a constructor argument on both machine providers. The ClientCredentialsOAuthProvider docstring says that when issuer is set, "token requests are only built from discovered authorization server metadata whose issuer is exactly this string; otherwise the flow stops with OAuthFlowError." For PrivateKeyJWTOAuthProvider, "an assertion is only minted, and token requests are only built," after that match.

# mcp >= 2.2.0 (or >= 1.30.0 on the 1.x line)
from mcp.client.auth.extensions.client_credentials import (
    ClientCredentialsOAuthProvider,
)

provider = ClientCredentialsOAuthProvider(
    server_url="https://inventory-tools.internal.example/mcp",
    storage=token_store,              # your TokenStorage implementation
    client_id=cfg.client_id,          # registered for THIS server only
    client_secret=cfg.client_secret,
    scope="inventory:read",
    issuer="https://login.yourcompany.example",  # pinned, not discovered
)

# Omitting issuer= warns today and is required in 3.0:
#   "Without it, the MCP server decides which authorization server
#    receives this client's credentials"

Pinning turns a silent redirect into a loud failure. If a server's metadata changes to name a different authorization server, your job stops with an error in the log instead of sending a secret somewhere new. That is the behavior the spec asks for with pre-registered credentials, now enforced before any request is built.

P1

Store the issuer with the credential

A credential record is (issuer, client_id, secret or key reference, allowed MCP server URLs). If your secrets manager holds the secret without the issuer, the binding lives in someone's memory.

P2

Discover, then compare to the pin

Let discovery run. Reject any metadata whose issuer is not exactly the pinned string. The spec already requires that discipline for the iss comparison: no case folding, no default port elision, no trailing slash cleanup. Apply it here too.

P3

Check the token endpoint belongs to the issuer

This is a local policy, not a spec rule: require that token_endpoint shares the issuer's origin, or appears in an allowlist you maintain. Some providers host endpoints on other hosts, so make exceptions explicit.

P4

Treat step-up as a new discovery

A 403 insufficient_scope with a new resource_metadata URI is the server asking you to trust again. Run the same checks. If the issuer changed mid-session, stop and alert.

Interactive clients cannot always pin

A desktop agent connecting a user to an arbitrary third party server has no issuer to pin in advance. That is the case Client ID Metadata Documents were built for. The client's ID is an HTTPS URL the client hosts, and the spec notes these IDs "are portable across authorization servers," so there is no pre-registered secret to steal. For these clients the protections are the ones in the table above, all of them, on every path: origin matched issuer, iss checked against the recorded value, PKCE, and a consent screen at a provider the user can recognize.

  • Public clients: no client secret to leak. The code and verifier are still at risk if metadata is not validated.
  • Show the user the issuer: if your UI names the authorization server before opening the browser, a user connecting a "calendar tool" who sees an unexpected identity provider has a chance to stop.

Credentials Per Connection

Pinning controls where a credential goes. Scoping controls how much damage it does if it goes somewhere wrong anyway. The spec already reasons this way across authorization servers: client identifiers "are unique to the authorization server that issued them," and clients "MUST maintain separate registration state (client credentials, tokens) per authorization server." Carry the same idea one level down, to the connection.

SHARED CREDENTIAL PER CONNECTION CREDENTIALS ───────────────── ────────────────────────── agent-runner (client_id A, secret S) agent-runner β”œβ”€β”€ mcp: crm-tools uses S β”œβ”€β”€ mcp: crm-tools β†’ client_id C1, scope crm:read β”œβ”€β”€ mcp: billing-tools uses S β”œβ”€β”€ mcp: billing β†’ client_id C2, scope billing:read β”œβ”€β”€ mcp: inventory uses S β”œβ”€β”€ mcp: inventory β†’ client_id C3, scope inv:read └── mcp: new-vendor-tool uses S βœ— └── mcp: new-vendor β†’ client_id C4, scope vendor:read βœ— one hostile server leaks S one hostile server leaks C4 S mints tokens for every audience C4 mints tokens for one audience, one scope rotation: every deployment rotation: one registration
🎯

Audience per server

RFC 8707 resource binds tokens to one MCP server. Servers "MUST validate that access tokens were issued specifically for them." A leaked token for C4 does not open C1.

πŸ”‘

Asymmetric where possible

RFC 9700 recommends asymmetric client authentication, such as mutual TLS or private key JWT. A leaked assertion expires. A leaked secret waits for you to notice.

βœ‚οΈ

Scopes from the challenge

Request what the WWW-Authenticate challenge asks for, and use scopes_supported only as a fallback. Least privilege at registration makes the leak smaller before it happens.

🧾

Inventory as code

A reviewed file mapping each MCP server URL to its issuer and client ID. New servers enter through review, which is where "that looks like an internal tool" gets checked.

Design Setup effort Exposure from one hostile server Recovery
One client secret for all MCP servers, no pin Lowest Every server and scope the secret can reach Rotate everywhere, audit every token issued
One client secret, pinned issuer Low Blocked at discovery on patched SDKs, full reach if the pin is missed anywhere Rotate everywhere if any path leaked
Per server client, pinned issuer, narrow scope One registration per server One audience, one scope set Rotate one registration
Per server client, private key JWT, pinned issuer Key management per client Short lived assertions only, key stays local Revoke one key if compromise is suspected

Failure Modes and Operations

The advisory's remediation list is short: upgrade to 2.2.0 or 1.30.0, pass issuer= to the two machine providers, clear stored registrations "to force re-registration with proper issuer binding," and if you were exposed, rotate client secrets and revoke tokens at the authorization servers. For older versions, Cycode's write-up is blunt: "there is no workaround other than connecting only to MCP servers you trust." Each step has a way to go wrong.

Upgraded, still unpinned

2.2.0 closes the fallback and step-up gaps by validating against the expected issuer on every path. Without issuer=, the expected issuer is still whatever protected resource metadata named, and protected resource metadata is the server's claim. The server can no longer forge your provider's identity from its own origin, but it can still send your client to an authorization server it runs honestly. The deprecation warning is the signal. Treat it as an error in CI.

Stale registrations after the upgrade

Cycode notes that registrations stored before 2.2.0 lack the issuer label, which is why the advisory says to clear them. A client that loads an unlabeled registration has no binding to check. Clearing storage forces re-registration, and the new record carries the issuer it was minted by.

Transitive SDK copies

The affected package is mcp on PyPI. Agent frameworks and internal tools vendor or pin it. Pin audits need to read the resolved lockfile, not the top level requirements. Stdio only clients are out of scope per the advisory, so a quick triage question is which of your clients speak HTTP to servers you do not operate.

Evidence lives at the authorization server

If a secret leaked, the attacker's first use of it is at your real authorization server's token endpoint. Look for client credential grants for that client_id from networks your jobs never run in, and for authorization code redemptions that did not come from your client's egress. The attacker's token endpoint will not send you logs.

When Action Check that proves it
Today Upgrade mcp to 2.2.0 or 1.30.0 everywhere it resolves Lockfile scan shows no version in the affected ranges
Today Add issuer= to every ClientCredentialsOAuthProvider and PrivateKeyJWTOAuthProvider No issuer deprecation warning in test runs
Today Clear stored OAuth registrations Re-registered records carry an issuer
This week Rotate secrets used by clients that connected to any server outside your control while unpatched Old secret rejected at the authorization server
This week Split shared client registrations into one per MCP server Inventory file maps each server URL to its own client ID
Ongoing Alert on issuer or authorization server changes in discovery A deliberate metadata change in staging pages someone
The Redirect Fix in the Same Release

The 2.2.0 notes also say that Client("https://..."), streamable_http_client and sse_client now "follow a redirect only if it stays on the same scheme, host and port," and servers get AuthSettings.validate_token_resource to accept only tokens issued for them. Both follow the same principle as the issuer check. An HTTP response is a claim, and a claim that moves your client to a new origin is a trust decision. Make it in configuration, not in whatever the last response said.

Where the Sources Disagree

Four places where the primary sources say different things. None changes the remediation, but you should know which document you are quoting.

Affected 2.x range

The GitHub advisory lists 2.0.0a1 through 2.1.1, which includes the prereleases. Cycode's write-up says 2.0.0 through 2.1.1. If you pinned an alpha or beta of 2.0.0, treat it as affected.

Affected classes

Cycode names three provider classes. The advisory names four, adding the deprecated 1.x RFC7523OAuthClientProvider. Audit for all four.

Is iss required of authorization servers?

The MCP blog's summary of the 2026-07-28 release says authorization servers "must return the iss parameter." The normative spec text says they "SHOULD include" it and that a future revision "is expected to upgrade" that to MUST. Clients reject a missing iss only when metadata advertises authorization_response_iss_parameter_supported: true. Build clients to the spec text.

CVE status

As of the advisory's publication on 28 September 2026, it states that no CVE is assigned. Track it by GHSA ID. Scanners keyed only on CVE may not flag it yet.

Key Takeaways

Principles

  1. The MCP server is untrusted input. Every discovery document is a claim from code you do not own. RFC 9728 leaves choosing a trustworthy authorization server to the client, which means to you.
  2. Validate issuer on every path, including the old ones. Legacy fallbacks and step-up re-authorization are discovery too. A check that runs on the main path and not on the compatibility path is a check with a hole in it.
  3. Pin the expected issuer for any credential you provisioned. Pass issuer= to machine providers on mcp 2.2.0 or 1.30.0. Without it, the server decides where your credentials go.
  4. Downstream defenses assume validated endpoints. PKCE, iss checks and audience binding all pass their own checks while protecting nothing if the token endpoint came from a forged document.
  5. One credential per connection. Separate client registrations per MCP server, with narrow scopes and resource-bound tokens, turn one hostile server into one rotation instead of a fleet incident.
  6. Prefer asymmetric client authentication. A private key JWT assertion expires and the key never leaves the client. A shared secret stays valid until someone notices.
  7. Clear state after the upgrade. Registrations stored without an issuer cannot be checked against one. Force re-registration.
  8. Watch the real authorization server, not the fake one. Leaked credentials show up as grants at your provider from places your jobs never run.
  9. Put MCP servers in reviewed inventory. The attack begins when someone adds a URL to a list. Make that list code, with the issuer and client ID next to each server.

In this catalog