^.^;
Back to Blog
Blog Post

Google OAuth Production: The PKCE Puzzle Nobody Tells You About

Why your OAuth works locally but returns 400 Bad Request in production. The PKCE configuration gotcha that costs hours of debugging.

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

Google OAuth Production: The PKCE Puzzle Nobody Tells You About

You've implemented Google OAuth. It works perfectly locally. You deploy to production and get:

400 Bad Request
error: invalid_request
error_description: PKCE verification failed

Welcome to the club. This is the PKCE puzzle.

What is PKCE?

PKCE (Proof Key for Code Exchange, pronounced "pixie") is a security extension to OAuth 2.0. It prevents authorization code interception attacks.

The Flow

1. Client generates random code_verifier
2. Client creates code_challenge = hash(code_verifier)
3. Client sends code_challenge to authorization server
4. Server stores code_challenge
5. User authorizes
6. Server sends authorization code back
7. Client sends code + code_verifier to token endpoint
8. Server verifies: hash(code_verifier) === stored_code_challenge
9. If match → issue tokens

Seems straightforward. So why does it break in production?

The Problem

Local Development

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

// Sign in works fine
await supabase.auth.signInWithOAuth({
  provider: 'google',
});

Result: ✅ Works perfectly

Production

Same exact code:

await supabase.auth.signInWithOAuth({
  provider: 'google',
});

Result: ❌ 400 Bad Request - PKCE verification failed

The Root Cause

The Supabase client initialization was missing the PKCE flow type in production.

Here's why this happens:

Local vs Production Defaults

Local (Next.js dev server):

// Supabase automatically uses 'pkce' flow
// Because it detects localhost

Production (Vercel):

// Supabase defaults to 'implicit' flow
// Because it's not localhost
// But Google OAuth requires PKCE!

The Fix

Explicitly set the flow type to pkce:

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

export const createClient = () => {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      auth: {
        flowType: 'pkce', // 👈 THIS IS CRITICAL
        autoRefreshToken: true,
        persistSession: true,
        detectSessionInUrl: true,
      },
    }
  );
};
👶

Explain Like I'm 3

Imagine you and your friend have a secret password game. At home, you always use the password. But when you went to school, you forgot to use it! So the teacher didn't know it was really you and said "no, I need the password!" PKCE is like remembering to use the password everywhere - at home AND at school. Now Google knows it's really you!

💼

Explain Like You're My Boss

The Supabase client library uses environment detection to choose OAuth flow types. In localhost, it defaults to PKCE (secure). In production, it defaults to implicit flow (legacy). However, Google's OAuth implementation mandates PKCE for security compliance. Without explicit flowType: 'pkce' configuration, production auth fails with 400 errors.

Business Impact: This single line fix resolved 100% authentication failures in production. 2.5 hours of debugging traced to implicit defaults vs. Google's security requirements.

💕

Explain Like You're My Girlfriend

You know how I act totally different at your parents' house vs. at my place? Like at home I'm relaxed but at your parents' I'm all formal and polite? The code was doing that - being one way at home (localhost) and a different way in production. But Google was like "I need you to be the SAME person everywhere!" So I had to tell the code "always be yourself (PKCE mode) no matter where you are." One line of code = consistent behavior = no more embarrassing auth failures. 😅💕

Why This Happens

The Supabase Client Decision Tree

if (window.location.hostname === 'localhost') {
  flowType = 'pkce'; // Safe default for local
} else {
  flowType = 'implicit'; // Legacy default for production
}

The problem: Google OAuth requires PKCE. So when Supabase defaults to implicit in production, Google rejects it.

The Complete Solution

1. Client Configuration

// 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', // Always use PKCE
        autoRefreshToken: true,
        persistSession: true,
        detectSessionInUrl: true,
      },
    }
  );
};

2. Server Configuration (for SSR)

// utils/supabase/server.ts
import { createServerClient } from '@supabase/ssr';

export const createClient = (cookieStore: ReturnType<typeof cookies>) => {
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      auth: {
        flowType: 'pkce', // Also critical for SSR
      },
      cookies: {
        get(name: string) {
          return cookieStore.get(name)?.value;
        },
        set(name: string, value: string, options: CookieOptions) {
          cookieStore.set({ name, value, ...options });
        },
        remove(name: string, options: CookieOptions) {
          cookieStore.set({ name, value: '', ...options });
        },
      },
    }
  );
};

3. Callback Route

// app/auth/callback/route.ts
import { createClient } from '@/utils/supabase/server';
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';

export async function GET(request: Request) {
  const requestUrl = new URL(request.url);
  const code = requestUrl.searchParams.get('code');

  if (code) {
    const cookieStore = cookies();
    const supabase = createClient(cookieStore);
    
    // Exchange code for session (uses PKCE verification)
    const { error } = await supabase.auth.exchangeCodeForSession(code);
    
    if (error) {
      console.error('Auth error:', error);
      return NextResponse.redirect(
        `${requestUrl.origin}/auth/error?message=${error.message}`
      );
    }
  }

  // Success - redirect to dashboard
  return NextResponse.redirect(`${requestUrl.origin}/dashboard`);
}

Testing PKCE Flow

How to Verify It's Working

  • Open Chrome DevTools → Network tab
  • Click "Sign in with Google"
  • Look for the authorization request

You should see:

GET https://accounts.google.com/o/oauth2/v2/auth?
  client_id=...
  &redirect_uri=...
  &response_type=code
  &scope=openid+email+profile
  &code_challenge=ABC123...      👈 This proves PKCE is active
  &code_challenge_method=S256    👈 Using SHA-256 hashing
  • After authorization, check the token exchange
POST https://your-project.supabase.co/auth/v1/token
{
  "grant_type": "authorization_code",
  "code": "...",
  "code_verifier": "XYZ789..."   👈 Sending the verifier
}

If you see code_challenge and code_verifier, PKCE is working!

Common PKCE Issues

Issue #1: Mixed Flow Types

// ❌ Client uses PKCE, server uses implicit
const clientSupabase = createClient(); // pkce
const serverSupabase = createServerClient(); // implicit (default)

Fix: Both must use PKCE.

Issue #2: Code Verifier Lost

If the code verifier is stored in localStorage but cleared before callback:

// ❌ User clears browser data between auth and callback
localStorage.clear(); // Verifier lost!

Fix: Supabase handles this internally - don't manually clear storage during auth.

Issue #3: Cookie Issues

PKCE state is stored in cookies. If cookies are blocked:

// ❌ Third-party cookies blocked
document.cookie = 'sb-access-token=...'; // Blocked in some browsers

Fix: Ensure sameSite: 'lax' and secure: true for production.

The Production Checklist

Before deploying OAuth:

  • [ ] ✅ Set `flowType: 'pkce'` in client
  • [ ] ✅ Set `flowType: 'pkce'` in server
  • [ ] ✅ Test in Vercel Preview deployment
  • [ ] ✅ Verify `code_challenge` in Network tab
  • [ ] ✅ Check callback route handles errors
  • [ ] ✅ Ensure cookies are set correctly
  • [ ] ✅ Test on multiple browsers
  • [ ] ✅ Test on mobile devices

Why PKCE Matters

Without PKCE (Implicit Flow)

Attacker intercepts authorization code
→ Immediately exchanges for tokens
→ Gains access to user account

With PKCE

Attacker intercepts authorization code
→ Tries to exchange for tokens
→ Server requires code_verifier
→ Attacker doesn't have it
→ Request rejected

PKCE makes authorization codes useless without the verifier.

The Bottom Line

**Always explicitly set flowType: 'pkce'** in your Supabase configuration.

Don't rely on defaults. They differ between local and production.

Quick Fix Template

// Copy this to your project
import { createBrowserClient } from '@supabase/ssr';

export const createClient = () => {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      auth: {
        flowType: 'pkce', // 👈 Never forget this
      },
    }
  );
};

That's it. Three lines that save hours of debugging.


Still getting PKCE errors? Check your Supabase Dashboard → Authentication → URL Configuration. The redirect URL must match EXACTLY.


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.