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 failedWelcome 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 tokensSeems 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 localhostProduction (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 browsersFix: 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 accountWith PKCE
Attacker intercepts authorization code
→ Tries to exchange for tokens
→ Server requires code_verifier
→ Attacker doesn't have it
→ Request rejectedPKCE 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