Tutorial
How an MCP client should pin its OAuth issuer: what Python SDK 1.30 and 2.2 still require after the upgrade
Putting OAuth on the server only decides who may call a tool. The 28 September advisory asks a client question: do you accept the login service discovery just named?
On 28 September 2026 the maintainers of the official MCP Python SDK confirmed that a vulnerable HTTP client could send the client secret, authorization code, and PKCE proof meant for the real login service to a token endpoint the other party controlled. The fixed releases are 1.30.0 on the 1.x line and 2.2.0 on the 2.x line. They shipped on 7 September, and the release notes called the change a behavior change. The advisory itself landed on 28 September, the same day Cycode published its research; security press carried it the next day. The write-up to start from is The Hacker News account. The client shape that is correct now is OAuth clients. How a server demands a token is still the on-site MCP OAuth 2.1 post. When the remote server is not one you fully control, the deploy shape is in remote MCP in production.
The advisory fixes the client, not a server you already protected
What is affected is a process using the official Python SDK as an HTTP client, with OAuth turned on. It connects to an MCP server it does not fully trust, while it holds credentials for a real login service. The maintainers' account is that some discovery paths never decided the expected login service before accepting the one the server named. The fixed releases decide the expected issuer first, then fetch authorization-server metadata. The issuer inside that document has to be that same party, or the client refuses it. Stored credentials are labeled with the login service, so a record labeled for A is not used to redeem a token at B.
This is a different layer from adding OAuth 2.1 to an MCP server. The server decides who may call a tool and whether the token was minted for this resource. The client decides whether the authorization server named in discovery is the one that issued the client secret. You want both. A protected server does not stop a client from following the wrong party during discovery. A patched client does not cover a server that still skips audience checks. That is a separate bug.
The change that landed on the main line is PR 3398. Authorization-server metadata has to name, as issuer, the server it was fetched from. That rule is RFC 8414 section 3.3. On a mismatch, stop. Do not add a client switch that ignores issuer.
| What you run | This advisory | Still to do after the upgrade |
|---|---|---|
| HTTP client, OAuthClientProvider | 1.9.1–1.29.1, or 2.0.0–2.1.1 | Move to 1.30.0 or 2.2.0 and drop old client_info |
| Client credentials or private-key JWT | The same version ranges | Also pass issuer |
| An SDK server, stdio, or your own token | Outside this advisory | Still move the dependency to a fixed release |
Separate who is in scope
On 1.x the range is 1.9.1 through 1.29.1. On 2.x it is 2.0.0 through 2.1.1. The classes are OAuthClientProvider, ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider, and the deprecated RFC7523OAuthClientProvider, which has no issuer argument. Cycode rated the issue high. Local stdio, clients that attach their own token, and deployments that only use the SDK to write a server are outside this advisory.
A narrow scope does not mean the repo can keep the old pin. A CLI, a nightly job, and a desktop client often share one mcp dependency in the same lockfile. The diagram says only the server uses the SDK, and a 1.29 HTTP client is still installed. Upgrade from the version in the lockfile, not from the role on the architecture slide.
On the old versions the advisory gives one temporary rule: an OAuth-enabled client connects only to servers you trust. If it has already connected to a server you do not trust, rotate the client secret at the login service and revoke the tokens that were issued. That step is about credentials that may already have left your environment. It is not an invitation to replay discovery.
After 1.30 or 2.2, machine-to-machine still needs issuer
The browser flow, OAuthClientProvider, decides the expected login service before it fetches metadata. The two flows without a browser do not. ClientCredentialsOAuthProvider and PrivateKeyJWTOAuthProvider need issuer set to the issuer value in that authorization server's /.well-known/oauth-authorization-server document. Discovery still runs, but token requests are built only from that party's metadata. If the MCP server points somewhere else, the flow stops with OAuthFlowError.
Leave issuer out and those two providers still follow whatever discovery returns, even on the fixed release. The advisory is explicit: without the argument, the upgrade does not reach machine-to-machine. On 1.30.0 the reminder is an ordinary DeprecationWarning, which Python hides by default, so CI logs often never show it. In 3.0 the argument becomes required. Set it now. Do not wait for the major version to fail the job.
RFC7523OAuthClientProvider has no issuer argument. Staying on it means the upgrade cannot pin the login service. Move to ClientCredentialsOAuthProvider or PrivateKeyJWTOAuthProvider, then pass issuer. Read the secret from the environment or a secret manager. Do not commit it.
In metadata you trust, issuer must equal the URL you fetched
Fetch the document from an authorization server you already trust, for example https://auth.example.com/.well-known/oauth-authorization-server. The issuer inside it has to be that server. Comparison is string equality, with no normalization. A trailing slash, a different host case, or a www prefix is a different value. Pass the client the exact string from the document. Do not type a URL that only looks the same.
This JSON is authorization-server metadata from a server you trust. issuer matches the origin you fetched, and the token endpoint is on that same host.
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
"code_challenge_methods_supported": ["S256"],
"authorization_response_iss_parameter_supported": true
}
Protected resource metadata only tells the client which authorization server protects this MCP server. It is not a substitute for the login service's identity. The client still fetches that server's metadata and checks issuer. If resource metadata names A and the authorization-server document calls itself B, stop. Do not pin B just to make the integration go green.
The 2026-07-28 revision demotes dynamic registration in favor of a Client ID Metadata Document. You publish one client JSON at a stable HTTPS URL, and that URL is the client_id. The SDK takes it as client_metadata_url. When the authorization server advertises support, the client no longer asks /register for a fresh id and secret. The URL has to be HTTPS with a non-root path, and that is checked at construction. Stored client_info still wins over the document, so an old record left in place means the new issuer pin never takes effect.
Drop old client_info, and rotate secrets as a separate step
The fixed release labels stored registrations with the login service. client_info written before the upgrade has no such label. The advisory says to delete those records so the next registration is created under the new rule and bound to the right party. Upgrade the package and keep the old file, and the process keeps handing out an unbound registration.
Deleting the record and rotating the secret are different jobs. Clearing client_info lets the new code register again. Rotating the client secret and revoking tokens at the login service matters because the credential may already have been presented to a server you do not trust. Without that history, you do not rotate every internal client. With that history, do it at the login service. Do not paste the secret into a chat log.
Hand state and iss from the callback back to the provider unchanged. The provider compares state with the value it generated and iss with the issuer it discovered. Those two fields are the mix-up checks. They are not debug fields. A callback parser that drops iss removes a check the fixed release just added.
Put the pinned issuer in the repo and review it as a contract
Give each MCP endpoint one record: the server URL, and the issuer you allow. Copy the value from the authorization-server metadata verbatim. Review the formatted JSON, not a hostname from someone's notes. Diff the file between releases and see who moved issuer onto another host.
Keep this map in the repo. Do not keep client_secret here. The secret stays in the secret manager. This file only pins the login service.
{
"clients": [
{
"mcp_server": "https://orders.example.com/mcp",
"issuer": "https://auth.example.com"
}
]
}
The machine-to-machine constructor and this list have to use the same string. The list says https://auth.example.com and the code adds a slash, and the fixed client treats that as a different party and stops. That failure is the one you want: the job goes red on OAuthFlowError instead of quietly switching token endpoints.
Before you commit, use the JSON formatter to spread out the metadata and the issuer list, the JSON Schema validator to confirm issuer and token_endpoint are strings, and JSON diff to see who changed the login service.
FAQ
If we only run a local stdio server, do we still care?
A stdio client is outside this advisory. If the same environment also has an HTTP client on this package, upgrade from that client's version. A lockfile that contains 1.9.1 through 1.29.1, or 2.0.0 through 2.1.1, moves to the matching fixed release. Do not skip it because the architecture slide says stdio.
We upgraded to 1.30.0 and the log shows no warning. Are we done?
No. When ClientCredentialsOAuthProvider or PrivateKeyJWTOAuthProvider is constructed without issuer, the reminder is a DeprecationWarning, hidden by default. Turn deprecation warnings on, or pass issuer in the constructor. A quiet log does not mean the argument is set.
Dynamic registration or a Client ID Metadata Document?
When the authorization server advertises client_id_metadata_document_supported, publish one client JSON at an HTTPS URL and use that URL as client_id. You stop asking /register for a secret on every meeting. If the server does not advertise it, the SDK falls back to dynamic registration. Either way, stored client_info wins, so delete the old record after the upgrade.
How does this sit next to the server-side OAuth 2.1 post?
That post is about the resource server demanding an access token. This one is about the client not following the wrong login service. When the remote server is not behind your own gateway, read both: the server checks the token, and the client pins issuer in the constructor and in the repo list.
Takeaways
The 28 September advisory removes "trust whoever discovery names" as the default. Move HTTP clients to 1.30.0 or 2.2.0. For machine-to-machine, pass an issuer that matches the metadata verbatim. Replace the deprecated RFC7523 provider.
Then delete old client_info. If the client has talked to a server you do not trust, rotate the secret and revoke tokens at the login service. Keep only the server URL and the issuer in git, and watch that string with a formatter, a schema check, and a diff.