DPDP Act 2023India’s data-protection law takes effect 13 May 2027.Read the Act
NamoID
All posts
NamoID Blog

OAuth Mix-Up Attacks: How RFC 9207 Shuts Them Down

You added PKCE, you check state, and the authorization-code flow feels locked down. There's one redirect attack those two don't touch, and it only bites clients that talk to more than one authorization server — which used to be rare and, in the MCP era, is now the default. It's the mix-up attack, and the fix is a single response parameter most authorization servers still don't send. NamoID emits that parameter, iss, on every authorization response — success and error alike — so below is what the attack is, why PKCE and state miss it, and exactly what both sides have to implement.

The fix is standardized as RFC 9207, OAuth 2.0 Authorization Server Issuer Identification. Published in 2022, it's short and still under-deployed. The MCP authorization spec is what's dragging it into the mainstream, because AI agents are exactly the multi-authorization-server clients the attack targets.

What an OAuth mix-up attack is

A mix-up attack tricks a client that supports several authorization servers into sending an authorization code (or token) to the wrong one — an attacker-controlled server — instead of the honest server that issued it. RFC 9207 puts it plainly: the attack aims "to steal an authorization code or access token by tricking the client into sending the authorization code or access token to the attacker instead of the honest authorization or resource server."

The precondition is the part to internalize: the client must support more than one authorization server. Here's the shape of it. Your client lets a user log in with either an honest server (call it H-AS) or a second one the attacker operates or has compromised (A-AS). During a flow the user means to run against H-AS, the attacker interferes with the round trip so your client loses track of which server actually answered. The redirect comes back with a valid code — issued by H-AS — but your client, believing the response came from A-AS, posts that code to A-AS's token endpoint. You just handed the attacker a code minted at the honest server, which they replay to get the user's tokens.

The root cause is narrow and specific: a standard authorization response contains code and state, but nothing that says which server sent it. The client is left to assume. Assumptions are what attackers price in.

Why PKCE and state don't stop it

PKCE and state are real defenses, but they guard different doors. Neither identifies the responding server, so neither closes the mix-up.

  • PKCE (RFC 7636, and our PKCE explainer) binds the code to a per-request secret so an intercepted code can't be exchanged. In a mix-up the code isn't intercepted — your own client willingly sends it to the wrong endpoint, and it carries the matching verifier right along with it.
  • state protects the integrity of the round trip — it's a CSRF defense that ties the response back to a request your client started. A mix-up keeps state intact; the request really did originate from your client. state confirms that you started a flow, not who answered it.

That gap — "who answered it" — is precisely what RFC 9207 fills.

            ┌─────────┐
            │ Client  │  supports H-AS and A-AS
            └────┬────┘
   starts flow   │   ...attacker mixes up the round trip...
                 ▼
       ┌───────────────────┐         ┌───────────────────┐
       │  Honest AS (H-AS) │         │ Attacker AS (A-AS)│
       │  issues the code  │         │  receives the code│
       └───────────────────┘         └───────────────────┘
                 │                              ▲
                 │  response: code + state      │  client posts the code here,
                 └──────────────────────────────┘  believing A-AS answered
            iss parameter is missing → client can't tell them apart

How RFC 9207 closes it: the iss parameter

RFC 9207 adds one parameter to the authorization response: iss, the issuer identifier of the server that created the response. The client compares it against the server it intended to use and rejects the response on any mismatch. That's the whole mechanism.

The value is the authorization server's issuer identifier as defined in RFC 8414 — the same string published as issuer in its discovery document. Per the spec, it "MUST be a URL that uses the 'https' scheme without any query or fragment components." On the wire it's percent-encoded like any other parameter. A NamoID success redirect looks like this:

https://app.example.com/callback?code=SplxlOBeZQQYbYS6Wx&state=af0ifjsldkj&iss=https%3A%2F%2Fissuer.example.com

Decode the iss, compare it to the issuer you started the flow with, and a mix-up is instantly visible: the code came back stamped with the honest server's identity, so a client tricked into thinking A-AS answered will see the stamp say H-AS and refuse to proceed. The attacker never gets a code posted to their token endpoint.

What the client must do (the other half)

RFC 9207 is a two-sided contract, and the client side is the half teams skip. Emitting iss does nothing if no one checks it. The spec is explicit: "Clients MUST extract the value of the iss parameter... and compare the result to the issuer identifier of the authorization server where the authorization request was sent to. This comparison MUST use simple string comparison... If the value does not match the expected issuer identifier, clients MUST reject the authorization response."

There's a subtler MUST that closes a downgrade: a client also has to reject a response that's missing iss when the server is known to support it. Otherwise an attacker just strips the parameter. The MCP authorization spec encodes this as a table keyed on the server's advertised support:

AS advertises iss supportiss in responseClient action
YesPresentCompare to the expected issuer (simple string comparison)
YesAbsentReject the response
No / unknownPresentCompare to the expected issuer anyway
No / unknownAbsentProceed (nothing to check)

The takeaway: "the server didn't send iss" is only safe when the server never claimed to. The moment it advertises support, absence is an attack signal.

iss rides on error responses too

Here's the part implementers miss. RFC 9207 requires iss on every authorization response, "including error responses." A denial isn't exempt. When a user clicks "Deny," the authorization server still redirects back to the client (per OAuth 2.1 §4.1.2.1), and that error redirect must carry the issuer identity so the client can validate it the same way:

https://app.example.com/callback?error=access_denied&state=af0ifjsldkj&iss=https%3A%2F%2Fissuer.example.com

We learned this one by reading the spec twice: our first pass added iss to the success redirect and left denials alone. They're a response too, and a client that validates iss on success but not on errors has a hole. NamoID now routes both paths — the API flow and the hosted-login consent screen — through the same redirect builder, so success and denial are stamped identically.

The client's reject-on-missing rule keys off one metadata field, so shipping iss without advertising it is half a feature. RFC 9207 §3 defines the flag; you set it in your authorization server metadata, served at /.well-known/oauth-authorization-server and the OIDC discovery document:

{
  "issuer": "https://issuer.example.com",
  "authorization_endpoint": "https://issuer.example.com/v1/oauth/authorize",
  "token_endpoint": "https://issuer.example.com/v1/oauth/token",
  "authorization_response_iss_parameter_supported": true
}

If you omit the field, its default is false, and a correct client will not enforce the missing-iss check against you — leaving the downgrade open. The flag and the parameter ship together or not at all.

The gotcha: iss must byte-match your issuer

Because the comparison is simple string comparison, the iss you emit has to equal your published issuer exactly — not a normalized-equivalent URL, the same bytes. A trailing slash is enough to break it: send iss=https://issuer.example.com/ against a discovery issuer of https://issuer.example.com and a spec-compliant client rejects every one of your responses.

We trim the trailing slash on the issuer before stamping it into the redirect, so the value the client decodes is identical to the issuer it read during discovery. NamoID also runs a distinct issuer per environment — each gets its own https://<env>.id.namoid.in identifier — so the rule holds per issuer: the response iss and that environment's discovery issuer have to match to the byte, custom domains included.

FAQ

What is an OAuth mix-up attack?

It's an attack on clients that support multiple authorization servers. The attacker tricks the client into sending an authorization code — issued by an honest server — to an attacker-controlled server's token endpoint, because a standard authorization response doesn't say which server produced it. RFC 9207's iss parameter removes that ambiguity.

Does PKCE prevent mix-up attacks?

No. PKCE stops an intercepted code from being exchanged. In a mix-up the client sends the code itself, along with the matching PKCE verifier, to the wrong endpoint. PKCE and state are necessary but don't identify the responding server, which is what the mix-up exploits. RFC 9207 is the dedicated defense.

Is RFC 9207 required for MCP?

The MCP authorization spec currently says authorization servers SHOULD include iss, and signals an intended upgrade to MUST. It already requires clients to validate iss and to reject responses that omit it when the server advertises support. Because MCP clients are general-purpose and connect to many servers, they are the multi-authorization-server clients mix-up attacks target — so implementing it now is the safe call.

What is the iss parameter in OAuth?

iss is an authorization-response parameter from RFC 9207. Its value is the authorization server's issuer identifier (the issuer from its discovery metadata), an https URL with no query or fragment. The client compares it to the server it started the flow with and rejects any mismatch.

Where NamoID fits

NamoID is a privacy-first, India-first OIDC issuer built for the multi-server world AI agents live in. It emits the RFC 9207 iss parameter on every authorization response — success and denial — advertises authorization_response_iss_parameter_supported in discovery, and pairs it with mandatory PKCE, refresh-token rotation, and audience-bound access tokens behind a single issuer URL. For the agent side of this, see securing an MCP server and MCP's OAuth 2.1 profile; for the wider OAuth 2.1 changes, the OAuth 2.1 vs 2.0 post has the full picture. Want a walkthrough before launch? Book 30 minutes at calendly.com/polymindslabs/30min or reach us at hello@namoid.in.

Related posts