Last updated: September 2026
On 28 September 2026 the Model Context Protocol maintainers published a High severity advisory (CVSS 7.5, GHSA-qx49-fqc8-xw99) for the official MCP Python SDK: a malicious MCP server could make an SDK-based client send its OAuth client secret, authorization code and PKCE verifier to a token endpoint the attacker controls. It affects mcp 1.9.1 through 1.29.1 and every 2.x release before 2.2.0 (including the 2.0.0 pre-releases), and the fix is in 1.30.0 and 2.2.0. Upgrading is not enough for machine-to-machine clients: you also have to pass issuer= so the SDK only sends credentials to the authorization server you actually trust. If your client may already have talked to a server you do not control, rotate its secret and revoke its tokens at that authorization server.
This post explains what the bug was in plain OAuth terms, who is exposed, why PKCE did not help, and what to change. The second half is about the authorization server side, using Keycloak as the example, because pinning an issuer only works if the issuer you pin is stable and the credentials behind it are cheap to replace. If you run identity management as a service for an AI product, or you run Keycloak yourself as the login service for your MCP tools, both halves apply to you.
What did the MCP Python SDK OAuth advisory say?
The advisory, “OAuth client could send credentials to an authorization server chosen by the MCP server” (modelcontextprotocol/python-sdk, GitHub Security Advisory GHSA-qx49-fqc8-xw99, published 28 September 2026), gives two root causes. The authorization server metadata issuer was not validated on every discovery path, and stored or pre-provisioned client credentials were not bound to the authorization server they belonged to. It has no CVE identifier at the time of writing.
To see why that matters, it helps to recall how an MCP client finds its login service. When an MCP server protected by OAuth answers a request with 401, the client reads the server’s Protected Resource Metadata (the RFC 9728 document at /.well-known/oauth-protected-resource), which names the authorization server. The client then downloads that authorization server’s metadata (the RFC 8414 document), which lists the token endpoint, the authorization endpoint and the registration endpoint. Every one of those URLs comes from documents the client fetched because an MCP server told it where to look.
RFC 8414 section 3.3 already covers this case: the issuer inside the metadata must be identical to the issuer the client expected, and if it is not, the client must not use the metadata. The SDK’s own v2 migration guide notes that v1 “accepted the metadata without checking”. Reading the SDK’s pre-fix code shows one practical route. When an MCP server published no protected resource metadata, the SDK asked the server’s own origin for authorization server metadata, and on the 1.x line it never compared that document’s issuer to anything, so the server could put any token endpoint it liked in the answer. On 2.x before 2.2.0 the issuer check ran only on the main discovery path, which left the same fallback unchecked.
What an attacker could collect
The advisory lists four things that could leave the client: the client secret, the authorization code, the PKCE code verifier and, for PrivateKeyJWTOAuthProvider, a signed client assertion. The client secret is the most serious, because it is long-lived and keeps working until someone changes it. The authorization code and the PKCE code verifier matter for interactive logins, and the second root cause, credentials not bound to their issuer, meant a client’s stored credentials could be presented to a server that was not the one they were registered with.
Put together, one malicious or compromised MCP server was enough to get a client to hand over credentials that belong to a real login service, such as your company’s Keycloak realm, and those credentials then work against that real login service.
Which MCP clients are affected?
An application is affected if all three of these are true. It uses the MCP Python SDK as a client over HTTP. It uses one of the OAuth providers the advisory names: OAuthClientProvider, ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider, or the deprecated 1.x RFC7523OAuthClientProvider. And it can connect to an MCP server it does not fully control while it holds credentials for a real authorization server.
| Situation | Exposed? | Why |
|---|---|---|
| Server-side code built with the SDK’s MCP server classes | No | The flaw is in the OAuth client providers |
| Client that only connects to MCP servers your team operates | Low | The attacker would need to compromise one of your own servers first |
| Agent platform that lets users add arbitrary MCP server URLs | Yes | Any user-supplied server can drive discovery |
CI job or backend using ClientCredentialsOAuthProvider without issuer= |
Yes, even after upgrading | Without an issuer, the MCP server still chooses where credentials go |
| Client on stdio transport only | No | No HTTP OAuth flow is involved |
The fourth row is the one most teams will miss. The fixed releases raise a deprecation warning when ClientCredentialsOAuthProvider or PrivateKeyJWTOAuthProvider is built without issuer=, and the SDK’s deprecation page says 3.0 will make the keyword required. Until you add it, those providers still follow whatever authorization server discovery finds. On the 1.x line the warning is a standard DeprecationWarning, which Python hides by default outside tests, so run your client or test suite with -W error::DeprecationWarning to find the call sites; 2.x raises MCPDeprecationWarning, which is visible by default.
Why didn’t PKCE protect the authorization code?
PKCE (Proof Key for Code Exchange, RFC 7636) stops someone who intercepts an authorization code from redeeming it, because the redeemer must also present the code verifier that only the real client knows. That works when the attacker sees the redirect but not the token request. In this bug the attacker was the token endpoint, so the client sent the code and the verifier together, in the same request, to the attacker.
This is a useful reminder about what PKCE is for. It binds the code to the client instance that started the login, and it has no way to check that the token endpoint belongs to the right authorization server. That check is the job of issuer validation, which is why the RFC 8414 rule, and the RFC 9207 iss parameter in the authorization response that the 2.x SDK now checks, exist alongside PKCE rather than being replaced by it. If you want a refresher on how PKCE is set up on the Keycloak side, our guide to creating a PKCE authorization flow client in Keycloak walks through it.
How do you fix an affected MCP client?
The advisory’s remediation comes down to five steps, and the order matters because the last two are only useful once the first three stop further leaks.
- Upgrade the SDK. Move to
mcp2.2.0 or 1.30.0. PyPI shows both were uploaded on 7 September 2026, three weeks before the advisory, so any pin from 1.9.1 up to 1.29.1, or any 2.x pin before 2.2.0, is affected. - Pass
issuer=to every unattended provider. Use the exactissuervalue your authorization server’s metadata returns, character for character. The deprecatedRFC7523OAuthClientProviderhas noissuerargument, so migrate it toClientCredentialsOAuthProviderorPrivateKeyJWTOAuthProvideras its deprecation notice says. - Clear stored dynamic registrations once. Registrations saved by an older SDK carry no issuer binding. Deleting them lets the client register again and record which authorization server it registered with.
- Set the issuer on pre-registered clients. If you store client records yourself, add the issuer to each record so the binding can be enforced.
- If exposure is possible, rotate and revoke. Rotate the client secrets and revoke the tokens at the real authorization server for any client that may have connected to a server you do not control.
For a client credentials job talking to a Keycloak realm, step 2 looks like this on the 1.x line (in 2.x the scope keyword is scope rather than scopes):
import os
from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider
auth = ClientCredentialsOAuthProvider(
server_url="https://mcp.internal.example.com/mcp",
storage=token_storage,
client_id="reporting-agent",
client_secret=os.environ["REPORTING_AGENT_SECRET"],
issuer="https://auth.example.com/realms/acme", # must equal the realm's issuer exactly
scopes="mcp:tools",
)
With issuer set, the SDK only builds a token request from metadata whose issuer matches that string. If an MCP server points the client anywhere else, the flow stops with an OAuthFlowError, which is exactly what you want to see in your logs when a server misbehaves.
One practical warning: the SDK compares your issuer= to the metadata issuer as a plain string. Its issuers_match helper only tolerates a bare origin with or without a trailing slash, which never applies to a Keycloak realm issuer because that URL has a path, so https://auth.example.com/realms/acme/ will not match. Copy the issuer from the metadata document rather than typing it, and expect some legitimate pairings that worked on older SDK versions to fail after the upgrade because their two URLs never quite agreed. That failure is the fix working, and the right response is to correct the deployment rather than look for a way around the check.
What should the Keycloak side look like?
On Keycloak, every realm has one issuer, https://<host>/realms/<realm>, and Keycloak publishes it in both the OpenID Connect discovery document (/realms/<realm>/.well-known/openid-configuration) and the RFC 8414 document (/realms/<realm>/.well-known/oauth-authorization-server). That fixed issuer is what your MCP clients pin, so the first rule is to keep it stable. Changing the hostname or renaming a realm changes the issuer, and every pinned client breaks at once.
The rest of the Keycloak work is about making sure that, if a credential does leak, it is narrow, short-lived and quick to replace.
Prefer signed JWTs over shared client secrets
Keycloak confidential clients can authenticate with a client ID and secret, or with a signed JWT (private_key_jwt), where the client proves possession of a private key and Keycloak only stores the public key. With a signed JWT there is no long-lived secret for a rogue token endpoint to collect. What it can collect is a single assertion, and Keycloak’s client assertion validator requires a jti identifier, rejects any assertion it has already seen, checks that the audience names your realm, and enforces a short expiry, so a captured assertion is at most one short-lived use rather than a standing credential. The SDK’s PrivateKeyJWTOAuthProvider supports this pattern, and it takes the same issuer= argument.
If you keep client secrets, make rotation routine
Keycloak supports client secret rotation through a client policy. The Keycloak Server Administration Guide (“Client Secret Rotation”) explains that, with the policy enabled, a client can have two active secrets at once, the new main secret and the previous one with its own expiry. Rotation does not happen in the background: it happens when someone regenerates the secret in the admin console or through the Admin REST API. For a suspected leak, regenerate the secret and make sure the rotated secret’s expiry is short or zero, so the old value stops working immediately rather than lingering for days.
We covered the wider problem of standing client secrets for machine identities in our post on JADEPUFFER and retiring static Keycloak secrets, and a related Keycloak exposure path in the Vault client secret leak, CVE-2026-17048.
Know what revocation does and does not cover
Keycloak’s documentation is explicit that signing out all active sessions does not revoke outstanding access tokens, which “must expire naturally”. The Revocation action on the Sessions page sets a not-before time that invalidates earlier tokens for anything that asks Keycloak whether a token is still good, but an MCP server that validates JWT access tokens locally will keep accepting them until they expire. The practical consequence is to keep access token lifespans for agent and MCP clients short, measured in minutes, so that “revoke” and “expire” are close together. If you need finer control over user sessions, our post on revoking a single Keycloak session covers that.
Scope tokens to the MCP server that asked for them
Pinning the issuer protects the client’s credentials, and audience restriction protects the tokens. A token minted for one MCP server should not work against another, which in Keycloak today usually means an audience mapper on a client scope. Our guide to the MCP server 401 and RFC 8707 audience fix on Keycloak shows the setup. Turning on admin events also records client updates, including secret regeneration, which is the first history you will want after an incident like this one.
How is this different from other MCP security issues this year?
Most of the MCP security issues we have written about sit on the server or gateway side. The LiteLLM bug in CVE-2026-59822 was a gateway failing to enforce authentication, and our guide to securing MCP servers with Keycloak covers how a server should validate tokens. This advisory is on the other side of the connection: a client trusting the server it is talking to for instructions about where to log in.
That makes it a classic confused deputy problem, where a program with legitimate credentials is tricked into using them on someone else’s behalf. The defence has two parts, and neither replaces the other. The client must decide for itself which authorization server it trusts, which is what issuer= does. The authorization server must make its credentials easy to replace and hard to reuse, which is the Keycloak work above. Client ID Metadata Documents, which we described in Keycloak CIMD for MCP authorization, also help here, because the SDK treats a CIMD client ID as portable by design while binding dynamically registered credentials to the server that issued them.
A short checklist for teams connecting AI agents to Keycloak
If you run agents or AI tooling that log in to MCP servers through Keycloak, this is the list we would work through this week.
- Inventory every Python MCP client that uses HTTP transport and an OAuth provider, and upgrade each to
mcp1.30.0 or 2.2.0. - Add
issuer=with the exact realm issuer to everyClientCredentialsOAuthProviderandPrivateKeyJWTOAuthProvider, and treat the deprecation warning as a build failure. - Clear stored dynamic client registrations once so they are recreated with an issuer binding.
- For any client that could have reached an untrusted MCP server, regenerate its secret in Keycloak with a short rotated-secret expiry, and revoke its tokens.
- Move service clients from client secrets to signed JWTs where the client can hold a key.
- Keep access token lifespans short for agent clients, and restrict each token’s audience to one MCP server.
- Turn on admin events so secret changes and client updates are recorded.
For more on giving agents their own identities rather than shared credentials, see our guide to AI agent authentication with Keycloak.
Frequently asked questions
Which MCP Python SDK versions are affected?
The advisory GHSA-qx49-fqc8-xw99 lists mcp 1.9.1 through 1.29.1 and every 2.x release before 2.2.0, including the 2.0.0 pre-releases, as affected. The fixed versions are 1.30.0 and 2.2.0, both released on 7 September 2026 and disclosed on 28 September 2026 with a CVSS 3.1 score of 7.5.
Is upgrading the MCP Python SDK enough?
Not on its own for unattended clients. The advisory pairs the upgrade with passing issuer= to ClientCredentialsOAuthProvider and PrivateKeyJWTOAuthProvider. Without it, the fixed SDK still lets the MCP server decide which authorization server receives your credentials, and warns you with a deprecation warning. Interactive OAuthClientProvider users should also clear stored registrations once so they are recreated with an issuer binding.
Does this affect MCP servers built with the Python SDK?
No. The advisory concerns the OAuth client providers the SDK uses when it acts as an MCP client over HTTP. Server code is not the vulnerable path, although any MCP server you operate should still validate token audience and issuer, as covered in our guide to securing MCP servers with Keycloak.
Why didn’t PKCE stop the attack?
PKCE protects an authorization code from being redeemed by someone who only intercepted the redirect. In this bug the client sent the code and the PKCE verifier together to the attacker’s token endpoint, so the attacker had both. Issuer validation under RFC 8414 is the control that prevents that, and PKCE works alongside it.
What is the issuer of a Keycloak realm?
A Keycloak realm’s issuer is https://<host>/realms/<realm>, with no trailing slash. It appears as the issuer field in the realm’s openid-configuration and oauth-authorization-server metadata documents, and that exact string is what you pass to the SDK’s issuer= argument.
Sources
- modelcontextprotocol/python-sdk, “OAuth client could send credentials to an authorization server chosen by the MCP server” (GHSA-qx49-fqc8-xw99), published 2026-09-28, retrieved 2026-09-29, https://github.com/modelcontextprotocol/python-sdk/security/advisories/GHSA-qx49-fqc8-xw99
- modelcontextprotocol/python-sdk, “Validate the authorization server metadata issuer on every discovery path” (#3398, and the 1.x backport #3431) and “Deprecate constructing the pre-provisioned OAuth clients without an issuer” (#3435), September 2026, retrieved 2026-09-29, https://github.com/modelcontextprotocol/python-sdk
- PyPI, “mcp” release history (1.30.0 and 2.2.0 uploaded 2026-09-07), retrieved 2026-09-29, https://pypi.org/project/mcp/
- IETF, RFC 8414 “OAuth 2.0 Authorization Server Metadata”, section 3.3, https://www.rfc-editor.org/rfc/rfc8414
- IETF, RFC 7636 “Proof Key for Code Exchange by OAuth Public Clients”, https://www.rfc-editor.org/rfc/rfc7636
- IETF, RFC 9207 “OAuth 2.0 Authorization Server Issuer Identification”, https://www.rfc-editor.org/rfc/rfc9207
- Keycloak, Server Administration Guide, “Client Secret Rotation” and “Revoking active sessions”, retrieved 2026-09-29 from the keycloak/keycloak repository, https://github.com/keycloak/keycloak/tree/main/docs/documentation/server_admin/topics