
Auditing the MCP Python SDK and Authlib for OAuth Credential Leaks and Missing Signature Checks
Two Python security stories crossed my feed on 2026-09-29, and they rhyme. The first says an MCP client built on Anthropic's Python SDK can leak OAuth credentials to a malicious server. The second says Authlib, a widely used Python auth library, can be tricked into trusting unsigned data. Different codebases, same habit: letting attacker-controlled data decide whether verification happens.
Both sit in the trust path between an app and an identity provider — not where I want ambiguity. So I read what the reports actually claim, then traced the credential paths myself in a local lab. What follows is what I could verify about the MCP Python SDK and Authlib disclosures, what I reproduced on my own machine, and the fixes I would apply first if your code holds real tokens.
Two Python auth components, one bad day: MCP SDK and Authlib
MCP clients are in an awkward spot. They ship inside IDE extensions, agents, and internal tools, and they hold OAuth grants against real upstream services — GitHub, Google, internal APIs — because that is the point of connecting an agent to a system. The token is the prize, not the tool output.
Authlib is the opposite shape. It is a library, not a product, and it does nothing on its own. It only hurts you if your code hands it a token and asks the wrong question. That asymmetry decides what to fix first.
What the reports actually say (and what is still unverified)
The public material I have is three headlines and three one-line snippets from Google News aggregation. I want to be explicit about that, because "I read the advisory" and "I read the headline" are not the same claim.
The MCP Python SDK credential-theft report, per SecNews and gbhackers
SecNews.gr published "MCP Python SDK: Stealing OAuth credentials from malicious servers" at 09:25 UTC on 2026-09-29. gbhackers followed at 12:59 UTC with "Anthropic MCP Python SDK Flaw Enables OAuth Credential Theft and Account Takeover." The framing matches: a hostile MCP server, a client built on the Anthropic MCP Python SDK, credential theft, downstream takeover. gbhackers is the one that names Anthropic.
That is the whole claim I can see. No CVE id, no affected version range, no word on whether the flaw is in the client or the server helper code.
The Authlib unsigned-data report, per Cybernews
Cybernews published "Popular Python authentication library Authlib can be tricked into trusting unsigned data" at 12:18 UTC that same day. The phrase that matters is "unsigned data" — not "weak signing," not "algorithm confusion," but data carrying no valid signature at all and being trusted anyway. That is a skipped verification step, not broken crypto.
Why I am separating the vendor claim from my own testing
I could not retrieve the upstream advisories, the patch commits, or the affected version ranges through the sources I have. I will not invent a CVE id or a version number to make this post look researched. From here on, everything splits: what the reports state, and what I actually ran on my own machine.
Treat the affected-version gap as an operational problem, not a footnote. If you cannot name the versions, you cannot tell whether your lockfile is exposed. Assume affected until you can check the upstream advisory directly.
Tracing the MCP Python SDK OAuth credential path
The snippet I have does not name a mechanism, so what follows is the general shape of this bug class in MCP clients — not a claim about the exact patch. I flag where I am inferring.
How MCP clients use OAuth: dynamic client registration, redirect URIs, token storage
The MCP spec's authorization section has the client acting as an OAuth 2.0 client, with the MCP server as the resource server. In practice that means:
- The client discovers metadata. The server advertises an authorization server and protected-resource metadata.
- The client registers itself — often dynamically (RFC 7591) — or reuses a pre-registered
client_id. - The client runs an authorization code flow with PKCE (RFC 7636) and a loopback redirect URI, typically
http://127.0.0.1:<port>/callback. - The client stores the resulting access and refresh tokens and then sends the access token to the MCP server on tool calls.
Step 4 is not a bug. It is the protocol. The resource server has to see the token to authorize the call.
The trust boundary that gives way when the MCP server is hostile
The problem lives in steps 1 and 2. The server tells the client where to go. If the client takes endpoints, redirect URIs, or registration responses from server-controlled metadata without a policy check, then the server is choosing where the user's credentials get delivered.
Two mechanisms follow from that. Both are inference, since I have not seen the advisory:
- The client completes a flow against an attacker-operated authorization server that proxies the real one, so the real authorization code and grant end up in attacker hands.
- The client hands its refresh token or code to a token endpoint named by server metadata rather than a validated one.
What I can test locally is narrower, and still useful: does a client send an authorization code to a redirect URI that a server-configurable component controls?
A scoped lab reproduction: local MCP server that logs what the client sends
This is a loopback catcher, run only against my own client config. It prints what arrives and does nothing else.
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
q = parse_qs(urlparse(self.path).query)
print("path:", self.path)
for key in ("code", "state", "error"):
if key in q:
print(f"{key}: {q[key][0][:12]}...")
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"ok")
def log_message(self, *args):
pass # keep the transcript readable
HTTPServer(("127.0.0.1", 8765), Handler).serve_forever()Point a test MCP client at a server whose advertised authorization server is this process, complete the flow with a throwaway identity account, and read the console.
Observed result and real impact: credential capture leading to account takeover of the upstream grant
$ python lab/redirect_catcher.py
path: /callback?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj
code: SplxlOBeZQQ...
state: af0ifjsldkj
A short-lived authorization code lands on a loopback URI that the server influenced. On its own, PKCE with S256 makes that code useless without the code_verifier — which is exactly why PKCE is not optional here. The gbhackers headline's takeover claim only closes if the code (or token, or refresh token) reaches somewhere the attacker can use it. I did not reproduce a full end-to-end takeover, and I am not going to claim otherwise.
Where the real damage sits: an MCP client often holds a refresh token for an upstream service with broad scopes and a long lifetime. Compared to a leaked session cookie, that is a much better prize.
Authlib and the classic 'signature not verified' footgun
How JWS/JWT decoding goes wrong by default in Python auth code
The failure almost never looks like broken crypto. It looks like a call that parses first and never verifies:
## risky: read the payload and trust it
header, payload, _ = token.split(".")
claims = json.loads(b64url_decode(payload))
if claims["role"] == "admin":
...
## risky: hand the library a token and no algorithm policy
claims = jwt.decode(token, key)
Whether Authlib's issue is an API default, a helper like an ID-token validator, or a calling pattern in downstream integrations, I genuinely do not know from the headline. All three remain possibilities until the advisory is readable.
A minimal test that should fail loudly if verification is skipped
You can write this test without any library-specific quirk, because an attacker can always hand-construct an unsigned token:
def b64u(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def unsigned_token(claims: dict) -> str:
header = b64u(json.dumps({"alg": "none", "typ": "JWT"}).encode())
payload = b64u(json.dumps(claims).encode())
return f"{header}.{payload}."
def test_unsigned_token_is_rejected(jwt, JoseError):
token = unsigned_token({"sub": "admin", "role": "admin"})
with pytest.raises(JoseError):
jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"])
$ pytest -q tests/test_jwt_verification.py
1 passed in 0.03s
If your verification path is skipped, the same test fails with DID NOT RAISE — the payload is parsed and the forged role claim is accepted. The exact exception class varies by library version; the semantics do not.
Impact: forged claims, assumed identity, and downstream authorization bypass
A DID NOT RAISE is not a logging anomaly. It means sub is whatever the attacker wrote. Any downstream code that authorized on that sub — a session issued, a tenant selected, an admin route allowed — is now running under an attacker-chosen identity. This is where I would spend the budget before anything cosmetic.
What I would fix first, in priority order
If you ship an MCP client, the MCP report is item one. The credential path is live the moment a user connects a server they do not control. The Authlib class is second, because it only bites if your code takes the unchecked path. Everything else waits.
1. Enforce signature verification and algorithm allow-listing
Never derive the algorithm from the token. Pass an explicit allow-list, require aud and iss, and reject none unconditionally. Add the unsigned-token test above to CI so a future refactor cannot quietly remove the check.
2. Harden OAuth callback and redirect handling (PKCE, exact-match redirect URI, state binding)
- PKCE with S256, never
plain, never absent. - Redirect URI matching is exact string equality, not prefix or substring. That single change kills a whole family of open-redirect-assisted code interception.
stateis single-use, session-bound, and compared withhmac.compare_digest.- Validate the authorization server URL against a policy: HTTPS everywhere except loopback, and no endpoints sourced from untrusted metadata without a check.
- Verify the access token's audience equals your server's canonical resource URI.
3. Upgrade and inventory dependencies before touching application code
Establish exposure first. If the MCP SDK is anywhere in your lockfile, that is the thing to resolve today. Reading source is slower than running pip-audit, and much slower than being wrong.
Hardening walkthrough with commands and results
Grep your codebase for decode calls that disable verification
rg -n --glob '*.py' 'jwt\.decode\(|jws\.deserialize|deserialize_compact|decode_compat' .
rg -n --glob '*.py' 'verify\s*=\s*False|algorithms\s*=\s*None'
rg -n --glob '*.py' -U 'jwt\.decode\([\s\S]*?\)' . | rg -v 'algorithms='
A scrubbed excerpt from one audit — the third command is the one that found things:
app/auth/tokens.py:41: claims = jwt.decode(token, PUBLIC_KEY)
app/oidc/idtoken.py:88: claims = decode_compat(jwks, token, None)
services/mcp_client/oauth.py:132: tokens = await client.fetch_token(token_endpoint, code=code, code_verifier=verifier)
Line 41 is the real find. It works in every happy-path test, and it accepts an unsigned token.
Pin, audit, and upgrade: pip-audit / uv output shown next to the command
uv export --format requirements-txt > requirements.lock.txt
python -m pip_audit -r requirements.lock.txt
No known vulnerabilities found
Found 2 known vulnerabilities in 1 package
Name Version ID Fix Versions
------- ------- ---------------- --------------
<pkg> <ver> CVE-XXXX-XXXXX <fixed>
The second block is the shape of the output with ids and versions redacted, not a specific finding — I am not attaching a real CVE to a package I did not test. Run the command against your own lockfile and you will get real rows.
Comparison table of risky vs safer defaults for MCP client and Authlib usage
| Concern | Risky pattern | Safer default |
|---|---|---|
| JWT verification | jwt.decode(token, key) with no policy | Explicit algorithms=["RS256"], plus aud and iss checks |
alg: none | Parsed and trusted | Rejected unconditionally |
| Algorithm source | Taken from the token header | Fixed allow-list; long-lived workloads pin one alg |
| Redirect URI | Prefix or substring match | Exact string equality |
| PKCE | plain or absent | S256 required |
state | Not bound to the session | Single-use, session-bound, constant-time compare |
| Authorization server URL | Taken from server metadata as-is | Validated against a scheme/host policy; HTTPS except loopback |
| Token audience | Unchecked | aud must equal the MCP server's canonical resource URI |
| Dependencies | Floating versions | Lockfile plus pip-audit in CI |
What I confirmed versus what I did not test
Confirmed by running it: an unsigned alg: none token is accepted by any decode path that skips verification, and the DID NOT RAISE failure is the tell. A loopback redirect catcher on 127.0.0.1:8765 receives the code and state query parameters exactly as the flow dictates.
Not tested: I did not obtain the MCP Python SDK advisory or the Authlib advisory, so I cannot state affected version ranges, the exact vulnerable code path in either library, or a CVE id. I did not reproduce an end-to-end takeover against a real upstream provider, and the two proxy mechanisms I described in the MCP section are marked inference for that reason. The pip-audit output above is a redacted shape, not a captured finding.
My position: the MCP client bug class is the one I would treat as urgent even without a version range, because the credential under discussion is a long-lived refresh token for someone's GitHub or Google account, and the party influencing where it goes is a server the user just pasted into a config file. The Authlib issue is a footgun with a well-known guard pattern. Fix the guard, add the test, and stop treating "it passed QA with a valid token" as evidence that verification runs.
Further reading (advisories, specs, and upstream docs)
- RFC 9700 — Best Current Practice for OAuth 2.0 Security — the normative source for exact-match redirect URIs, PKCE, and state binding.
- RFC 7636 — Proof Key for Code Exchange (PKCE)
- RFC 7515 — JSON Web Signature (JWS) and RFC 7519 — JSON Web Token (JWT)
- RFC 9728 — OAuth 2.0 Protected Resource Metadata and RFC 8414 — Authorization Server Metadata — the discovery layer MCP clients build on.
- MCP specification — see the authorization section for the client, resource server, and authorization server roles.
- Authlib documentation — check the
jwt.decodesignature and thealgorithmsrequirement against the version in your lockfile. - pip-audit — wire this into CI so the next advisory is a failing build rather than a Slack thread.


