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.
ββββ ββββ ββββββββββββββ βββββ βββββββββββββββββββββ ββββββββββββββ ββββββββ ββββββββββββββ βββββββ βββ βββ ββββββββββββββ βββ βββ ββββββββββ βββββββ ββββββ βββ βββββββββββββββ βββ ββββββββββββββββββββ βββββββββββββββ βββ βββ ββββββββββββββ βββ βββ ββββββββ βββ ββββββββββββββ βββ βββ ββββββββ ββββββββββββ ββββββββββββ βββ βββ βββ βββββββ βββ βββ βββββββ βββ βββ βββ
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."
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.
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.
Client secrets, authorization codes, PKCE verifiers and signed JWT client assertions, all sent to a token endpoint the attacker chose.
Validate issuer on every path, and let the client declare its expected issuer up front with issuer=, which becomes required in 3.0.
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 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.
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.
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.
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."
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."
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 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.
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."
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.
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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 |
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.
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.
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.
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.
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 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.
Four places where the primary sources say different things. None changes the remediation, but you should know which document you are quoting.
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.
Cycode names three provider classes. The advisory names four, adding the deprecated 1.x RFC7523OAuthClientProvider. Audit for all four.
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.
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.
issuer= to machine providers on mcp 2.2.0 or 1.30.0. Without it, the server decides where your credentials go.iss checks and audience binding all pass their own checks while protecting nothing if the token endpoint came from a forged document.resource-bound tokens, turn one hostile server into one rotation instead of a fleet incident.New deep-dives on systems architecture, delivered when they ship.