^.^;
Back to Blog
Blog Post

Debugging OAuth in Production: When Google Authentication Mysteriously Fails

OAuth worked perfectly locally but failed silently in production. Here's the debugging journey that uncovered a cascade of configuration issues.

J
JG
Author
2025-11-10
Published
◆ ◆ ◆

Debugging OAuth in Production: When Google Authentication Mysteriously Fails

The Problem: OAuth authentication with Google worked flawlessly in local development. Users could sign in without issues. Then we deployed to production and... nothing. Silent failures. No error messages. Just an infinite loading state.

This is the debugging story.

The Symptoms

✅ Local: Google OAuth works perfectly
❌ Production: Users click "Sign in with Google" → Loading spinner → Nothing

No console errors. No network failures. No database errors. Just... silence.

The Investigation

Step 1: Check the Obvious

First check: Are the environment variables set?

# Vercel Dashboard → Settings → Environment Variables
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co ✅
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... ✅

All there. So why isn't it working?

Step 2: Console Logging Everything

Added logging to the Supabase client initialization:

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

console.log('Supabase URL:', process.env.NEXT_PUBLIC_SUPABASE_URL);
console.log('Client initialized:', !!supabase);

Result: Client initialized successfully. URLs looked correct.

Step 3: Network Tab Analysis

Opened Chrome DevTools Network tab and watched the OAuth flow:

1. POST /auth/v1/token → 200 OK
2. Redirect to accounts.google.com → 302
3. User authorizes → Callback to /auth/callback
4. ??? (This is where it dies)

The callback URL wasn't triggering anything.

Step 4: Callback Route Investigation

Checked the /auth/callback route:

// app/auth/callback/route.ts
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const code = searchParams.get('code');
  
  if (code) {
    const supabase = createClient();
    await supabase.auth.exchangeCodeForSession(code);
  }
  
  return NextResponse.redirect(new URL('/', request.url));
}

Looks fine. But wait...

The Root Cause(s)

There were THREE issues:

Issue #1: Missing PKCE Configuration

The local Supabase client had PKCE enabled by default (development mode), but production didn't.

Fix:

// utils/supabase/client.ts
export const createClient = () => {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      auth: {
        flowType: 'pkce', // 👈 This was missing!
      },
    }
  );
};
👶

Explain Like I'm 3

Imagine you have a secret handshake with your friend to make sure it's really them. At home (local), you always do the handshake. But when you went to the playground (production), you forgot to do the handshake! So nobody knew if it was really you. PKCE is like that secret handshake for websites - it makes sure nobody is pretending to be you when you log in!

💼

Explain Like You're My Boss

PKCE (Proof Key for Code Exchange) is a critical security extension for OAuth that prevents authorization code interception attacks. Without it explicitly configured in production, the auth flow degrades to a less secure method, making our application vulnerable to man-in-the-middle attacks. This is especially critical for SPAs and mobile apps.

Security Impact: Missing PKCE exposes ~500 daily users to potential account takeover attacks. This one-line fix prevents a critical security vulnerability.

💕

Explain Like You're My Girlfriend

You know how when you leave the house, you always text me "leaving now" so I know it's safe? But then that one time you forgot to text and I panicked because I saw someone in our driveway and didn't know if it was you? That's what happened here. The app was supposed to send a secret code (flowType: 'pkce') to make sure it's really Google talking to us, but it forgot to do that in production. So the app was like "is this really Google or is someone pretending?" and just froze. One line of code = peace of mind! 😅💕

Issue #2: Callback URL Mismatch

In Google Cloud Console, the authorized redirect URI was:

https://myapp.vercel.app/auth/callback

But in Supabase Dashboard → Authentication → URL Configuration, I had:

https://myapp.com/auth/callback

Two different domains! Of course it failed.

Fix: Made them consistent.

Issue #3: Cookie Domain Issues

The auth cookies were being set for localhost even in production due to misconfigured cookie options.

Fix:

export const createClient = () => {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      auth: {
        flowType: 'pkce',
      },
      cookies: {
        get(name) {
          return getCookie(name);
        },
        set(name, value, options) {
          setCookie(name, value, {
            ...options,
            domain: process.env.NODE_ENV === 'production' 
              ? '.myapp.com' 
              : undefined,
          });
        },
        remove(name, options) {
          deleteCookie(name, options);
        },
      },
    }
  );
};
👶

Explain Like I'm 3

Cookies are like name tags that help websites remember you. But imagine if your name tag said "HOME" on it, and then you went to school wearing it. The teacher would be confused! That's what happened - the website was wearing a "localhost" name tag at the real website, and nobody recognized it. Now we give it the right name tag for wherever it is!

💼

Explain Like You're My Boss

HTTP cookies have a domain attribute that determines which domains can access them. Our cookies were hardcoded to localhost, which meant they were invisible to the production domain. This caused session persistence failures - users would "log in" but immediately appear logged out on page refresh because the browser couldn't read the auth cookies.

Business Impact: 100% authentication failure rate in production. Zero successful logins until this was fixed. Critical P0 bug that blocked launch.

💕

Explain Like You're My Girlfriend

Remember when we moved apartments and I kept accidentally going to the old address? Like for 2 weeks I'd drive to the old place out of habit? That's what the cookies were doing. They were set for localhost (the old apartment) but the app was running on myapp.com (the new apartment). So every time someone logged in, the cookie showed up at the wrong address and nobody was home. Now the cookies know where we actually live! Also I promise I know our real address now. 😅💕

The Solution: Production OAuth Checklist

Here's my checklist now for every OAuth integration:

1. Supabase Configuration

// ✅ Enable PKCE
auth: {
  flowType: 'pkce',
}

2. Environment Variables

# ✅ Verify all are set in production
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
NEXT_PUBLIC_SITE_URL=

3. Callback URLs

# ✅ Must match EXACTLY in:
- Google Cloud Console (Authorized redirect URIs)
- Supabase Dashboard (Site URL + Redirect URLs)
- Your code (callback route)

4. Cookie Configuration

// ✅ Set correct domain in production
cookies: {
  set(name, value, options) {
    setCookie(name, value, {
      ...options,
      domain: isProduction ? '.yourdomain.com' : undefined,
      sameSite: 'lax',
      secure: isProduction,
    });
  }
}

5. Testing Strategy

# ✅ Always test OAuth in production-like environment
- Use Vercel Preview deployments
- Test with real domain (not localhost)
- Check cookies in Application tab
- Verify network requests complete

Lessons Learned

  • Local != Production: Just because OAuth works locally doesn't mean it'll work in production
  • Silent failures are the worst: Always add logging to auth flows
  • Check everything twice: Callback URLs, environment variables, cookie domains
  • PKCE is not optional: Always enable it explicitly
  • Test in production early: Don't wait until launch day

The Fix in Action

After applying all three fixes:

✅ User clicks "Sign in with Google"
✅ Redirects to Google authorization
✅ User authorizes
✅ Callback URL triggered correctly
✅ Code exchanged for session
✅ Cookies set with correct domain
✅ User redirected to dashboard
✅ Authentication persists across page reloads

Total debugging time: 4 hours

Number of deploys: 12

Coffee consumed: 3 cups

Satisfaction: Priceless

Production OAuth Template

Here's the complete working implementation:

// utils/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr';

const isProduction = process.env.NODE_ENV === 'production';

export const createClient = () => {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      auth: {
        flowType: 'pkce',
        autoRefreshToken: true,
        persistSession: true,
        detectSessionInUrl: true,
      },
      cookies: {
        get(name) {
          return getCookie(name);
        },
        set(name, value, options) {
          setCookie(name, value, {
            ...options,
            domain: isProduction ? '.myapp.com' : undefined,
            sameSite: 'lax',
            secure: isProduction,
          });
        },
        remove(name, options) {
          deleteCookie(name, {
            ...options,
            domain: isProduction ? '.myapp.com' : undefined,
          });
        },
      },
    }
  );
};

The Bottom Line

OAuth in production is not the same as OAuth in development. You need:

  • Explicit PKCE configuration
  • Matching callback URLs everywhere
  • Proper cookie domain configuration
  • Production environment testing

Don't learn this the hard way like I did.


Debugging OAuth issues? Questions about Supabase auth? Let me know!


James G. - AI Alchemist | Full-Stack Developer

END OF ARTICLE
J

About JG

Full-stack developer specializing in web performance, authentication systems, and developer experience. Passionate about sharing real-world debugging stories and optimization techniques.

Terms of ServiceLicense AgreementPrivacy Policy
Copyright © 2025 JMFG. All rights reserved.