DEV Community

Alexandru A
Alexandru A

Posted on

Chasing a Ghost: Debugging a Silent Google OAuth Failure in Production

Yesterday I launched Evaficy Smart Test on Product Hunt — an AI-powered QA platform I've been building solo. Quiet launch, but that's not really what this post is about.

This morning I woke up to intermittent invalid_grant errors on Google Sign-In in production. No clear pattern. No reliable way to reproduce it by clicking around myself. Just the occasional 500, logged with no obvious trigger next to it.

Here's how I tracked it down — because the actual cause turned out to be something I wouldn't have guessed, and I suspect it's more common than people realize.

The symptom

Users occasionally hit a 500 error trying to sign in with Google. Not every time. Not for every user. No visible pattern in when it happened.

The stack trace pointed at passport-oauth2:

TokenError: Bad Request
    at OAuth2Strategy.parseErrorResponse
    at OAuth2Strategy._createOAuthError
Enter fullscreen mode Exit fullscreen mode

Classic invalid_grant — Google rejecting an authorization code as invalid, expired, or already used.

The obvious suspects (all wrong)

My first instinct was double-invocation — somewhere, the same authorization code was probably being sent to Google twice. Common causes:

  • A router mounted twice (I actually found a comment in my own code from a past bug: // Bug fixed: was '/auth/google' (double prefix since router is mounted at /auth) — promising lead, right?)
  • A React useEffect firing twice, maybe due to StrictMode
  • The OAuth callback route being hit twice in the middleware chain I checked all three. All clean. The router was mounted exactly once. The frontend button did a plain window.location.href redirect — no React state, no effects anywhere near the callback. The middleware chain had no loop-back.

Dead ends, but useful ones — they narrowed the search.

The clue that mattered

I asked for the actual server logs instead of trying to reproduce the bug live (reproducing intermittent bugs by clicking around is a great way to burn an afternoon for nothing). One log entry stood out:

"url": "/auth/google/callback?iss=https%3A%2F%2Faccounts.google.com&code=...
&scope=email+profile+...&authuser=2&prompt=none"
Enter fullscreen mode Exit fullscreen mode

prompt=none.

My actual "Continue with Google" button used prompt: 'select_account' — it always produces an interactive flow with a visible account picker. It would never generate a prompt=none callback. That single query parameter meant this request wasn't coming from a real click on my button at all. Something else was silently triggering Google auth in the background.

The actual cause

A quick grep through the frontend turned up this, still mounted at the app root:

<GoogleOAuthProvider clientId="...">
  <Authentication />
</GoogleOAuthProvider>
Enter fullscreen mode Exit fullscreen mode

GoogleOAuthProvider is from @react-oauth/google — a different, client-side OAuth pattern than the one I was actually using. My real login flow was 100% server-side: browser redirect → Express → passport-google-oauth20 → session. Textbook, and it worked fine on its own.

But at some earlier point I'd started migrating toward the client-side flow, wired up the provider, wrote a useGoogleLogin hook — then abandoned it and commented the hook out. The provider, though, stayed mounted.

Here's the part that got me: commenting out the hook doesn't stop the provider from doing anything. GoogleOAuthProvider loads Google's Identity Services script the moment it renders, regardless of whether anything downstream actually consumes its context. That script can independently trigger a silent/background re-authentication check using the same client_id — completely decoupled from any button click. Since that client ID's redirect URI pointed at my real callback endpoint, the silent flow's (possibly stale or already-consumed) code was landing on my production auth route and getting rejected by Google as invalid_grant.

Dead code, alive side effects. The import was unused. The component it wrapped didn't need it. But the script it loaded was doing something the whole time.

The fix was one line of removal — dropping the provider wrapper entirely, since nothing in the active code path used it. I also hardened the callback handler itself, since TokenError was throwing before passport's failureRedirect logic could catch it, turning a normal auth failure into an unhandled 500:

router.get('/google/callback',
    (req, res, next) => {
        passport.authenticate('google', (err, user, info) => {
            if (err || !user) {
                return res.redirect(`${clientUrl}?auth_error=google`);
            }
            req.logIn(user, (loginErr) => {
                if (loginErr) return next(loginErr);
                next();
            });
        })(req, res, next);
    },
    (req, res) => { /* existing session logic */ }
);
Enter fullscreen mode Exit fullscreen mode

Takeaways

  • Dead code isn't always inert. An unused import can still execute side effects the moment it's rendered/loaded — check what a component does on mount, not just whether anything reads its output.
  • A single unexplained query parameter can be the whole story. prompt=none was the one detail that ruled out every "obvious" theory and pointed straight at the real cause.
  • Logs before reproduction, when the bug is intermittent. I never had to manually trigger this bug once — the production logs had already recorded exactly the parameter I needed to diagnose it. If you're running Google OAuth alongside any client-side Google library (@react-oauth/google, google-one-tap, etc.) — even ones you think are unused — it's worth checking whether they're still mounted somewhere, quietly doing their own thing in the background.

Building Evaficy Smart Test, an AI-powered QA/test case generation platform, solo. Happy to answer questions about the debugging process or the product itself in the comments.

Top comments (0)