
The previous post, When an MCP Client Trusts Multiple Authorization Servers: Stopping Mix-Up Attacks with RFC 9207, laid out the theory thoroughly: how the authorization-server mix-up attack works, why neither state nor PKCE stops it, and why RFC 9207’s iss parameter is the missing piece. But understanding the concept is one thing. Watching a valid authorization code get stolen and minted into a real access token is another thing entirely.
This is the hands-on companion. I take the 03-oauth-mcp/issuer-identification sample from go-training/mcp-workshop apart piece by piece: a malicious authorization server (evil-as), an MCP client that hand-rolls its OAuth flow, and an honest MCP resource server — three real Go programs you can actually run. You’ll trigger the attack yourself and watch the attacker’s terminal print STOLEN ACCESS TOKEN; then add one -defense flag and watch the same attack get cut off — by an iss comparison — before it does any harm.
⚠️ Field-test note (2026-07-18): I tested this against the current latest Claude Code,
2.1.214— and it does not validate the RFC 9207issparameter when it runs the OAuth flow for an MCP server. In other words, the mix-up attack demonstrated here works against it: if even one of the authorization servers it trusts is malicious or compromised, an attacker can steal the code-for-token exchange exactly as in Scenario 2 below, with no visible sign on the victim’s side. Be careful before you connect Claude Code to any MCP server / authorization server you don’t fully trust. I haven’t individually tested the other MCP clients (the various CLIs and desktop apps), but given the shared structural gap — RFC 9207 is a client-side responsibility that most implementations haven’t built in yet — I’d assume they very likely have the same problem. Don’t assume your client covers this for you. The good news: the official SDK roadmap (SEP-2468) already schedulesissvalidation for the betas, so this hole should close in future versions — until then, staying alert is on you.
1. The three actors
The attack needs four services on stage together. The sample ships the first three as runnable Go programs; the fourth — the “honest authorization server” — is an external Signet instance (the same one the neighboring dcr/ and client-credentials/ examples use):
| Program | Role | Default address |
|---|---|---|
mcp-client/main.go | MCP OAuth client — hand-rolled Auth Code + PKCE, optional RFC 9207 check | callback :8085 |
evil-as/main.go | The malicious authorization server the client is tricked into trusting | :9090 |
mcp-server/main.go | Honest MCP resource server, one Bearer-protected who_am_i tool | :8095 |
| Signet (external) | Honest authorization server, the one the attacker impersonates | :8080 |
Here is how they relate. Note that evil-as and Signet are two different authorization servers, and the victim client trusts both of them at once — that is the single precondition the mix-up attack needs:
sequenceDiagram
actor U as User (browser)
participant C as mcp-client<br/>(MCP OAuth client)
participant E as evil-as<br/>(malicious AS :9090)
participant H as Signet<br/>(honest AS :8080)
participant M as mcp-server<br/>(resource server :8095)
Note over C: User picked "log in via the evil MCP server"<br/>→ expected issuer = evil-as
U->>C: start login
C->>E: GET /authorize (discovered from evil-as metadata)
E-->>H: 302 impersonation redirect<br/>(replays honest client_id, redirect_uri, PKCE)
U->>H: authenticate + consent
H-->>C: 302 callback?code=VALID&state=…&iss=http://localhost:8080
alt -defense OFF (vulnerable)
C->>E: POST /token (code sent to evil's token endpoint)
E-->>C: evil captures the code, redeems it at Signet for a real token
Note over E: prints STOLEN ACCESS TOKEN
else -defense ON (RFC 9207)
Note over C: iss=…8080 ≠ expected …9090 → abort
C--xE: code never reaches the attacker
end
Note over C,M: only the honest path (expected issuer = Signet) reaches here
C->>M: call who_am_i with the validated Bearer
M-->>C: identity claims
2. The crucial premise: the SDK does not do RFC 9207 for you
Before we dissect the attack, we need to be clear about why this sample hand-rolls everything.
As the previous post mentioned, the official MCP SDK roadmap already schedules RFC 9207 iss validation (SEP-2468, landing in the 2026-07-28 betas). But this sample pins the current stable go-sdk v1.6.1 — and in that version:
auth.AuthorizationResulthas onlyCodeandState; there is noIssfield. The SDK never hands you theissfrom the authorization response at all.oauthex.AuthServerMetaalso lacks theauthorization_response_iss_parameter_supportedflag.
| |
That is the teaching core of the whole sample: today, RFC 9207 protection is a client-side responsibility, not something the SDK grants for free. So, exactly like the neighboring dcr/ sample hand-rolls its PKCE flow (the SDK has no extension point for resource=), this client reads the iss off the callback itself, fetches the flag from metadata itself, and does the comparison itself. That gap is the lesson.
3. The attacker: what evil-as actually does
The malicious authorization server does exactly three things, one per HTTP endpoint. Let’s take them one at a time.
3-1. Masquerade as a normal AS (/.well-known)
To get the client to “trust” it, evil-as first has to look like a conformant RFC 8414 authorization server. It advertises its own /authorize and /token — and, most deviously, claims it supports RFC 9207:
| |
Why would an attacker advertise a mechanism that will expose it? Because it is betting the client does not validate. Claiming support makes it flawless in the eyes of an unprotected client; against a protected client it would be caught either way, so the extra line costs nothing. This also demonstrates a key point about RFC 9207: the metadata claim alone means nothing — what actually protects you is the one comparison the client performs against iss.
3-2. The core trick: “hand the user off” to the honest AS (/authorize)
The whole essence of the mix-up lives in this endpoint. evil-as’s /authorize authenticates no one — it 302-redirects the browser straight to the honest AS’s (Signet’s) /authorize, forwarding all of the victim client’s parameters untouched:
| |
These few lines look unremarkable, but look closely at which fields it forwards — state, code_challenge, redirect_uri, all copied verbatim. This is the code-level proof of the “state useless, PKCE useless” row in the previous post’s table:
- Why doesn’t
statehelp? The attacker forwards the victim’s ownstate. When Signet later redirects the code back to the client, it carries that samestate, so the client’s comparison matches perfectly.statedefends against CSRF (someone else’s response injected into your session), but here the entire flow was initiated by the victim. - Why doesn’t PKCE help? The attacker forwards the victim’s own
code_challenge. Later the client redeems with its owncode_verifier— just at the wrong place (evil-as). Both ends of the PKCE binding (challenge and verifier) stay in the victim’s hands and always match. The attacker never has to break PKCE; it only borrows the road.
When Signet receives this request, it sees a completely legitimate authorization request: a valid client_id, a registered redirect_uri (http://127.0.0.1:8085/callback, the victim client’s own callback). The user sees the familiar Signet consent screen, clicks approve, and Signet 302-sends a valid, working authorization code straight back to the victim client’s callback.
Here’s the crux: that code never passes through evil-as. The attacker doesn’t need to intercept it — it relies on the client “thinking it’s still talking to evil-as.”
3-3. The harvest: capture the code at /token and mint a real token
If the client skips RFC 9207 validation, it dutifully sends the code it just received to the token endpoint it thinks it’s talking to — evil-as’s /token. The theft happens here:
| |
Two details here are worth stopping on:
- Even the
code_verifieris volunteered by the victim. To redeem, the client POSTs its PKCE verifier along with the code. The attacker thus holds bothcodeandcode_verifier— both PKCE keys.redeemAtHonestonly has to replay the entire form as-is to Signet’s token endpoint to succeed, because everything it needs —code,code_verifier,redirect_uri,resource— is already in there. - It returns Signet’s response verbatim. After getting the real token,
evil-ascopies Signet’s success response straight back to the victim client. So the victim’s terminal looks entirely normal — token obtained, MCP connected,who_am_ianswered — with zero indication the token has leaked. That “silent success” is the most insidious part of the mix-up.
4. The defender: the client-side RFC 9207 check
Now the victim’s side. The client fills in the piece the SDK is missing, in three steps.
4-1. Read the iss off the callback
The SDK won’t give you iss, so read it yourself. The client pulls it straight from the query in its callback handler:
4-2. Fetch the flag from metadata yourself
oauthex.AuthServerMeta doesn’t surface authorization_response_iss_parameter_supported, so the client hits the metadata document again just to parse that one bool (defaulting to false on any error):
4-3. Byte-for-byte comparison — the heart of the whole sample
The actual defense is one function. It nails down all four cases from RFC 9207 §2.4:
| |
expectedIssuer is the trusted baseline the client took from the metadata of the AS it discovered (meta.Issuer); iss is the value the authorization response carried back. When the attack runs:
- The client originally discovered
evil-as, soexpectedIssuer = http://localhost:9090. - But the code was redirected back by Signet, so the callback’s
iss = http://localhost:8080. - They don’t match →
validateIssuerResponsereturns an error → the client aborts before it sends the code anywhere.
And this comparison sits before the code is sent — that timing is essential:
| |
The -defense flag controls exactly one thing: whether to run this check before sending the code. Turn it off and the client degrades to the fragile behavior the previous post described — “decide where to send the code based only on what my session remembers.”
5. Running the three scenarios
Now that the code makes sense, let’s run it. You need a Signet honest AS at http://localhost:8080, with a client registered there whose redirect URI is http://127.0.0.1:8085/callback. Because everything is on localhost, the SDK’s loopback exception permits plain HTTP.
Step 0: confirm Signet actually emits iss
The defense scenarios depend entirely on the honest AS truly stamping iss on the redirect and advertising the flag. Confirm before you start:
You want "authorization_response_iss_parameter_supported":true. If it’s false or absent, this Signet build doesn’t implement RFC 9207 and the defense can’t trigger — which reinforces the previous post’s point: the iss line of defense only holds when both server and client are in place.
Start the two long-running services
| |
On startup evil-as discovers Signet’s authorize/token endpoints and logs evil-as impersonation target discovered. Pass -redeem=false if you want it to only log the capture and not actually mint a token.
Scenario 1: honest path (defense on, reaches the tool)
The client discovers Signet directly, iss matches, the code is redeemed at Signet, and the client reaches the MCP tool:
A browser opens; after login and consent the client logs iss OK — issuer matches the discovered authorization server, then connected to the MCP server and returns the who_am_i identity claims. This is the “everything correct” baseline.
Scenario 2: mix-up attack, defense OFF (the code is stolen)
This time point the client at evil-as (:9090), and omit -defense:
After login and consent, look at terminal 2 (evil-as) — you’ll see the attack unfold in full:
The thing to really absorb is terminal 3 (the client itself): because evil-as returns Signet’s response verbatim and -connect defaults on, the victim client’s terminal shows an apparently successful run — token obtained, MCP connected, a normal who_am_i result printed. Nothing on the victim’s side signals the theft. The only evidence is on the attacker’s terminal. That silent success is exactly why the mix-up is dangerous, and why the next scenario matters.
Scenario 3: mix-up attack, defense ON (the client aborts)
Same setup as scenario 2, plus one -defense:
This time the client logs:
and then exits before ever contacting evil-as’s token endpoint. Back on terminal 2 you’ll see only the /authorize redirect line — no CAPTURED authorization code. The attacker gets nothing. One flag, one string comparison, and the attack is cut off before it does any harm.
6. Verifying without any server: the unit test
The four branches of the iss comparison are pinned by a table-driven test that needs no server at all:
| |
| |
That supported but iss mismatched (the mix-up) row is the pure-function version of scenario 3 — the entire attack distilled into one iss != expectedIssuer decision.
Wrapping up
This sample turns the previous post’s abstract argument into three concrete behaviors you can watch with your own eyes:
evil-as/handleAuthorize— those lines forwardingstateandcode_challengeverbatim are the ironclad proof of whystateand PKCE structurally can’t stop a mix-up: the attacker doesn’t break them, it borrows the road.evil-as/handleToken— returning Signet’s response as-is demonstrates why the attack is “silent”: the victim sees a successful login while the token is already in the attacker’s hands.validateIssuerResponse— one byte-for-byte comparison turns the client’s judgment of “where did this code come from” from a guess into a check, and the attack breaks before the code goes out.
And one conclusion that’s easy to skip but matters most to implementers: in today’s go-sdk v1.6.1, RFC 9207 is the client’s responsibility — the SDK won’t do it for you. AuthorizationResult has no Iss, AuthServerMeta has no flag, so you read, fetch, and compare yourself, exactly as this sample does. Once the official SDK betas (SEP-2468) land, this hand-rolled code will be replaced by a built-in — but until then, this gap is a hole you patch yourself.
And don’t file this under “someone else’s bug” — even the current Claude Code 2.1.214 doesn’t do this check yet (I tested it; see the note up top), so this isn’t a distant hypothetical, it’s a risk you’re exposed to the moment you connect an MCP server. If you’re writing or choosing an MCP client, my advice is blunt: clone this sample and run all three scenarios once. When you see that STOLEN ACCESS TOKEN line with your own eyes, then watch -defense make it disappear, your grasp of why every authorization response must sign itself will be firmer than reading the RFC ten times.
Full code and a step-by-step runbook here: https://github.com/go-training/mcp-workshop/tree/main/03-oauth-mcp/issuer-identification.