TLDR: When an OAuth provider rotates refresh tokens, you must write the new one back to disk on every single refresh. Read-once-from-
.envquietly breaks the moment the old token is invalidated.
What We Were Building
I've been building a QuickBooks Online MCP server — MCP (Model Context Protocol) is Anthropic's open standard that lets Claude talk directly to external tools and APIs as if they were built in.
The idea: Claude can read invoices, query expenses, and run reports against a real QuickBooks account, no copy-paste required.
OAuth 2.0 was the gating step. You go through the flow, land a refresh token, drop it in .env, and the server uses it to mint fresh access tokens on demand.
It worked. For a while.
The Wall
Then it just… stopped.
Silent breakage. No error on startup. No crash. Just API calls returning 401s when you actually tried to do something.
My first instinct was expiry — token must have aged out. Re-mint it by hand, stuff it back in .env, it works again. Fine.
Except it happened again.
What I Got Wrong First
I kept treating this as an expiry problem. Which it wasn't.
QuickBooks doesn't just let a token expire. It rotates — every time you exchange a refresh token for a new access token, Intuit quietly retires the old refresh token and hands you a fresh one.
The bug: the server was doing the exchange correctly in memory, using the new access token for that session… and then throwing the new refresh token away.
Next startup: reads the original token from .env. That token is dead. 401.
It worked in dev because I'd restart infrequently. In any remotely real scenario, you'd hit this within days.
The Fixes (There Were Three)
The main one: after each OAuth token refresh, write the new refresh token back to .env immediately. Not next restart. Now. The fix was persist rotated refresh token to prevent silent breakage.
The adjacent gotcha: the .env path was being resolved relative to cwd (wherever you launch the process from), not relative to the module file. Run it from a different directory and it silently reads nothing.
fix(auth): resolve .env path relative to module, not cwd
The credential leak: .env was tracked in git. Caught that during this pass and renamed it to .env.example, with the real file in .gitignore. chore(env): rename tracked .env to .env.example. Should have been day one, honestly.
Also hardened the OAuth callback server to work headless and behind ngrok (ngrok, a tunnel that gives your local server a public URL) — fix(auth): make OAuth callback server robust for headless and ngrok setups — because otherwise the initial auth flow was fragile in any non-browser environment.
Why This Sticks With Me
Rotation and expiry look identical from the outside: auth works, then stops. But the fix is completely different.
Expiry: re-mint. Rotation: you already have the new token — you just failed to save it.
The lesson is stupidly simple once you see it: if a provider rotates on refresh, your persistence layer has to be a write-through cache, not a read-once config value. Every refresh is a write.
I'd been building auth flows for years and still missed this one. That's the part that got me.
P.S. The Google OAuth 7-day gotcha is a different beast entirely — that's expiry (from a Testing-mode consent screen), not rotation. Same symptom, different root cause, different fix. Don't let them bleed together like I did initially.