Scope of this post
This is not a general MCP OAuth tutorial. It covers exactly one thing: why desktop MCP clients (Claude Desktop, Cursor, Claude Code, custom native apps) fail during the redirect step of OAuth, and what to do about each failure.
What's explicitly not here:
- PKCE and authorization server discovery — that's its own post, already covered in depth elsewhere on this blog.
- Token validation and audience checks — separate concern, separate post.
- Full production hardening (rate limiting, client allowlisting policy, session revocation) — that's the checklist-level work, and it's dense enough that it lives in the paid pack, not a blog post.
If you're debugging a redirect_uri error right now, this is the post. If you're trying to figure out your entire auth architecture, read the discovery/PKCE post first.
Why this specific piece breaks so often
Desktop apps aren't web apps. They can't sit at a public URL waiting for a redirect. So every desktop MCP client fakes it: it spins up a short-lived local HTTP server, tells the authorization server "send the code here," and tears the server down once the callback lands. MCP clients like Claude Desktop and Cursor are desktop apps, not web apps, so the standard approach is to start a temporary local HTTP server, use that as the redirect URI, and shut it down after receiving the callback.
That handoff — client picks a port, registers it, then has to send back the exact same string later — is where almost every desktop OAuth bug in MCP actually lives. Here are the four that show up over and over in real issue trackers, not hypotheticals.
Gotcha #1: localhost vs 127.0.0.1 are not the same string
This is the single most common desktop redirect failure. OAuth redirect matching is exact-string, not semantic. Some MCP clients register redirect URIs with localhost, but then send 127.0.0.1 during the OAuth flow (or vice versa), and according to the OAuth spec these are different strings, so redirect URI validation fails even though they're semantically identical loopback addresses. It's not user error — it's usually a bug in the client's own networking layer. It's usually a client bug: the client might use different network libraries for registration vs. the actual callback, or the OS might resolve localhost differently in different contexts.
Fix on your server: normalize both sides. Don't just normalize at registration time — normalize again at token exchange, because clients can flip-flop between the two calls independently. One team building MCP OAuth against Clerk found that normalizing the redirect_uri again at token exchange (always to localhost) to match what was registered was necessary because clients can be inconsistent at different steps — some register with localhost but send 127.0.0.1 during token exchange, or vice versa — and normalizing only at registration wasn't enough.
Gotcha #2: dynamic ports break exact-match validation against a fixed document
If you're using Client ID Metadata Documents (CIMD) instead of per-client dynamic registration, your redirect_uris list is static — but the client's actual callback port is not. Claude Code, for example, picks a random port every run. Claude Code uses dynamic ports for its OAuth callback server (e.g., http://localhost:8080/callback or http://localhost:60351/callback). If your CIMD document only lists a bare http://localhost/callback, that doesn't match a port-bearing URI at all, because http://localhost:8080/callback does not match http://localhost/callback because no-port defaults to port 80 per RFC.
Fix: use wildcard ports in your CIMD redirect_uris, which the spec explicitly supports: the CIMD document should use wildcard ports to match Claude Code's actual callback behavior — "http://localhost:*/callback", "http://127.0.0.1:*/callback" — and this wildcard syntax is supported by the CIMD spec and by FastMCP's redirect_validation.py. If your server framework doesn't support wildcard matching yet, your fallback is forcing Dynamic Client Registration instead, since server operators can disable CIMD support so the client falls back to Dynamic Client Registration, which works because the client registers its own redirect URIs.
Gotcha #3: custom URI schemes get rejected outright
Some desktop clients skip the loopback server entirely and register an app-specific scheme like myapp://oauth-callback instead of an http://localhost address. This looks fine until you hit an authorization server that enforces the stricter native-app rules. One real-world report showed a desktop app sending a custom scheme, claude://claude.ai/mcp-auth-callback/sdk to a server whose authorization endpoint accepts only https:// or loopback redirects — meaning the exact same client worked fine against one host and failed against another with no code change on the client side at all.
Fix: if you're building the server, decide upfront and document which redirect schemes you accept — loopback, HTTPS, or a specific custom scheme allowlist — and reject silently-different behavior between hosts. If you're building the client, prefer loopback over custom schemes unless you have a specific platform reason not to; it's the pattern every major authorization server actually tests against.
Gotcha #4: registering localhost, sending 127.0.0.1 at the platform-policy level
This isn't just a client quirk — some identity platforms have opinions about which loopback form is even valid, and those opinions can silently break dynamic-port native apps. Microsoft Entra, for instance, only does port-agnostic matching for localhost, not 127.0.0.1: Entra's port-agnostic loopback matching works only for localhost; dynamic-port native apps therefore require localhost. A client that defaults to 127.0.0.1:52446 against an Entra-backed MCP server will hit a hard rejection — the browser lands on the Entra error AADSTS50011, that the redirect URI specified in the request does not match the redirect URIs configured for the application — even though the exact same client works fine against a server that uses standard RFC 8252 loopback matching.
Fix: if you're behind Entra or a similarly strict IdP, standardize on localhost for your loopback redirect, not 127.0.0.1, and test both forms in CI against your actual authorization server before shipping — don't assume RFC 8252 behavior is universal.
Quick reference: which fix goes where
| Symptom | Root cause | Fix location |
|---|---|---|
redirect_uri_mismatch on otherwise-correct flow |
localhost vs 127.0.0.1 string mismatch | Server: normalize at registration and token exchange |
| CIMD rejects a valid dynamic-port callback | Static redirect_uris list, dynamic client port | Server: wildcard port syntax in CIMD, or force DCR |
| Client works against one server, fails against another | Custom scheme accepted inconsistently | Server: explicit scheme allowlist, documented |
| AADSTS50011 or similar IdP-specific rejection | Loopback host policy differs per IdP | Server: standardize on IdP's supported loopback host |
Where this fits into the bigger picture
Fixing these four gotchas gets your desktop OAuth flow working. It does not tell you whether your redirect validation is safe — that's a different question, and a more consequential one, because loose redirect validation is a real attack surface: if your validation is too loose (wildcards, broad localhost rules, arbitrary custom schemes), a malicious client can register a redirect that routes sensitive data somewhere unsafe, and OAuth history is full of "slightly wrong redirect validation → stolen codes → stolen tokens."
That's the part this post deliberately leaves out: how tight is "too tight," what to log when a redirect gets rejected, and how to scope what a compromised or misconfigured OAuth client can actually reach once it does get a token. If you've had an agent or MCP client misbehave — or you just want a structured way to think through blast radius before it happens — that's exactly what the AI Agent Incident Postmortem & Permission-Scoping Template Pack is built for: a template for writing the postmortem and tightening scopes after something goes wrong, so the next redirect URI bug doesn't turn into a token leak.
For now: fix your loopback string matching, wildcard your CIMD ports, pick one redirect scheme and stick to it, and test against your actual IdP's loopback policy instead of assuming RFC 8252 is universal. That's the whole gotcha, scoped tight on purpose.
Written with AI assistance and reviewed for accuracy.
Top comments (0)