
Our OAuth flow worked for Claude Desktop on the first try. The same server, the same spec, the same endpoints — Codex could not authorize against it at all. It took five root causes stacked on top of each other before it did, and the last one still annoys me.
Here they are in the order we actually found them, including the false victories in between.
The symptom
User clicks authorize. The email code arrives, they type it in, the browser redirects. Codex says: "verification failed."
Now the part that made this miserable: our server logs for the entire flow showed nothing but 200, 201, and 302. Not a single error, on any request, anywhere. Every hop of the failure was invisible from the server side.
It took access-log timestamps, a screen recording, and user-agent telemetry lined up on one timeline before the chain made any sense.
Layer 1: the stream that never ends
During verification, Codex issues a bare GET against the MCP endpoint — and waits for the response to complete.
Our endpoint answered that GET with a never-closing keepalive event stream. We kept it deliberately: mcp-remote's probe GET is header-identical to a legacy SSE client, so you cannot tell them apart, and breaking the stream breaks those users.
So Codex's validator sat there until its own multi-minute timeout expired.
The fix that held up was splitting on Accept semantics instead of trying to identify the client:
if "text/event-stream" not in accept:
# not asking for a stream — answer and close (11ms in prod)
else:
# hold the stream open, as mcp-remote expects
Fixed it. Retried. Still failed.
Layer 2: the listener that died waiting
That minutes-long stall wasn't just slow — it outlived the one-shot OAuth callback listener Codex had opened on 127.0.0.1:.
By the time the user finished typing the email code, the server's perfectly good 302 was redirecting into a port with nobody listening.
That is the entire anatomy of the fake "verification failed": every broken hop was a client-side timeout, and the server never saw anything go wrong.
With layer 1 fixed, the listener window should have survived. Some retests passed now. Some didn't. Which brings me to the layer I'm least proud of.
Layer 3: the retests were lying
On macOS, quitting the desktop app only kills the UI. An app-server daemon keeps running underneath, and together with leftover browser tabs from earlier attempts it happily resurrected dead auth sessions into my "clean" retests.
I lost an embarrassing amount of time to results that were really artifacts of the previous run.
The boring fix: pkill the entire process family before trusting any retest. If your OAuth debugging feels non-deterministic, check who's still alive first.
Layer 4: our own authorize page
While staring at listener windows, we had to admit our authorize page was part of the problem — three entry paths including an API-key paste box, and slow enough to flirt with the timeout.
So we surveyed how eight vendors running remote MCP connectors do it (Notion, Linear, Sentry, Atlassian, GitHub, Asana, Stripe, plus Cloudflare's oauth-provider defaults):
8/8 do existing-session → single consent screen → one-click Allow
0/8 offer an API-key paste box on the auth page
We rebuilt ours to match: one click, about two seconds. Faster, cleaner, better under directory review.
And codex mcp login still failed.
Layer 5: the killer
Everything environmental was ruled out, so what remained had to be in the protocol exchange itself.
Our authorization server metadata declared:
"authorization_response_iss_parameter_supported": true
RFC 9207. And an accurate statement — we do send iss on every redirect, error branches included.
The Codex build in question reads that declaration and enforces it: the callback must carry iss. Its loopback listener, though, is older code that doesn't parse iss at all.
Declare the capability, get executed by it.
The clean room
Proving that required eliminating everything else, because a browser auto-opening means you never know what actually hit the listener first — and a one-shot callback channel is decided by its first request.
Two tricks made it work:
codex mcp login scripts the entire flow headlessly, so the official CLI became the test harness.
The BROWSER env var does nothing for this code path (webbrowser::open ignores it — ask me how I know), so a PATH shim turned open into a no-op.
Then I built the callback by hand server-side and delivered it to the loopback port myself.
The listener received exactly one request in its lifetime: code, iss, state, all present, all correct.
Response: missing issuer.
At that point there is nothing left to blame but the parser. Control group: Claude Desktop does no such enforcement — same server, first-try pass.
The fix
One line: stop declaring the capability.
We still send iss on every redirect. We just no longer announce it — and what isn't announced isn't enforced.
codex mcp login: Successfully logged in.
When a build ships a listener that parses iss, we'll re-declare — after testing with the CLI, not after reading a changelog.
What I'd tell past me
Every capability you declare in metadata is a contract some client will enforce against you. Declaration surface isn't a completeness contest. An accurate declaration plus a buggy client equals your outage.
And when a flow fails with spotless server logs, stop reading the server logs. Build the timeline from the client side — the server was never going to confess.
I build Humaux Memory, a remote MCP server that gives AI agents shared long-term memory. These notes live in it, which is the dogfooding loop. Happy to answer questions in the comments — especially if you're staring at "verification failed" with clean logs right now.
Top comments (0)