TLDR: Build your OAuth callback server so it can be fed the redirect URL by hand over loopback. Tunnels are flaky. Auth codes survive a 502.
the setup
I'm wiring a specialist-locator tool for a cancer education business — a project that helps cancer patients locate nearby specialists — into QuickBooks Online (QBO) through a custom MCP server (the tool layer that lets AI agents call external APIs like QBO's).
OAuth through Intuit's production environment is…a lot.
Three walls. Each one dumber in hindsight than it was in the moment.
wall #1 — localhost is rejected
Production QBO refuses http://localhost as a redirect URI. It needs public HTTPS.
So you spin up a cloudflare quick-tunnel:
cloudflared tunnel --url http://localhost:8000
That gives you an ephemeral public hostname. Register <tunnel>/callback under the app's Production → Redirect URIs, then run npm run auth.
Good news: the tunnel is only needed for the initial handshake. Once you have a refresh token, it persists and the tunnel can die.
wall #2 — the dev keys are INERT
This one I chased too long before realizing what was happening.
The Client ID and Secret I'd captured while the app was still in development? Useless for production OAuth. Intuit issues production its own separate credentials. Authorizing with dev keys fails silently — nothing breaks loudly, it just…won't connect.
Fix: once Intuit approves the app, go to Keys & credentials → Production tab, re-pull both values, overwrite .env. Don't trust pre-unlock keys.
(The production Client ID is completely different from the dev key. Classic.)
wall #3 — the tunnel 502'd right on the callback
This is the interesting one.
I clicked Authorize in QBO. Intuit redirected the browser to <tunnel>/callback?code=…&state=…&realmId=…. Cloudflare returned a 502.
Bad gateway. On the exact callback. The local auth server was up and listening. Cloudflare just hiccupped at the wrong moment.
First instinct: click Authorize again.
Wrong — auth codes are single-use.
the fix that worked
The key insight: a 502 means the tunnel failed, not the auth server. The code sitting in the browser's address bar? Intuit already issued it. The local server never consumed it, so it's still valid for a few minutes.
Steps:
- Confirm the server is still listening:
lsof -nP -iTCP:8000 -sTCP:LISTEN - Copy the full URL from the browser address bar, deliver it over loopback:
curl "http://localhost:8000/callback?code=…&state=testState&realmId=…" - The server already holds
client_idandclient_secretin memory — it does the token exchange itself and writesREFRESH_TOKEN+REALM_IDto.env.
No secret rides in that curl. Only the public authorization code.
Not a hack. That's exactly what the callback server is for.
why this matters
This whole hardening pass — headless and ngrok/tunnel robustness, persisting the rotated refresh token so it doesn't break silently, resolving the .env path relative to the module instead of cwd — every single one of these was a real edge case that bit us in production.
Build your callback server so a human can feed it the redirect URL manually.
Tunnels die. Edge hiccups happen. An auth code is recoverable if the local server is still listening and you know to check the address bar.
The tunnel is just a delivery mechanism. The server is the thing that matters.