What actually changed
You wired Google or GitHub OAuth into your Supabase app. Sign-in works. Then a teammate shares a screenshot of their browser after login and the address bar reads:
https://yourapp.com/auth/callback#access_token=eyJhbGciOi...&refresh_token=vLo...&expires_in=3600&token_type=bearer
The tokens sit there until the user navigates away. If they click an external link, that URL β tokens included β is sent in the Referer header to the destination. If they bookmark the page, the bookmark stores the tokens. This has been tracked in supabase/auth-js#455 since the implicit flow shipped, and it is the single most common OAuth misconfiguration in Supabase apps.
The fix
Two paths. The right one is PKCE. The fast one is a hash cleanup on the callback page.
Option A β switch to PKCE (recommended)
PKCE moves the token exchange server-side. The browser only ever sees a single-use authorization code in the URL, not the tokens themselves.
// src/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
auth: {
flowType: 'pkce', // β replaces the 'implicit' default (still supabase-js's default)
autoRefreshToken: true,
detectSessionInUrl: true,
},
},
)
}
Do the same on the server client (createServerClient from @supabase/ssr accepts the same auth.flowType option). Then update your OAuth call to use a code-challenge-friendly redirect:
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
})
On the callback page, exchange the code for a session:
// src/app/auth/callback/route.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/dashboard'
if (code) {
const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) return NextResponse.redirect(`${origin}${next}`)
}
return NextResponse.redirect(`${origin}/login?error=oauth`)
}
After this, the URL on callback is /auth/callback?code=abc123 β a single-use, short-lived code. No tokens leak even if the user shares the URL.
Option B β strip the hash (quick patch)
If a PKCE migration is blocked by a release freeze, patch the callback today:
// src/app/auth/callback/page.tsx
'use client'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
export default function CallbackPage() {
const supabase = createClient()
const router = useRouter()
useEffect(() => {
supabase.auth.getSession().then(() => {
// Consume the hash, then destroy it from the address bar + history.
window.history.replaceState(
{},
'',
window.location.pathname + window.location.search,
)
router.replace('/dashboard')
})
}, [supabase, router])
return <p>Finishing sign-inβ¦</p>
}
replaceState (not pushState) is the key β it overwrites the hashed URL in history so the back button does not resurrect the tokens.
Verifying the fix
- Sign in with OAuth in an incognito window.
- After redirect, the address bar must read
https://yourapp.com/dashboardβ no#access_token=β¦. - Open DevTools β Network β click any outbound request to a third-party domain. The
Refererheader must be clean. - With PKCE: check the Supabase dashboard β Authentication β URL Configuration. The redirect URL and site URL must match your exact origin (no trailing slash, correct scheme). A mismatch makes
exchangeCodeForSessionreturnauth_invalid_codeand drop you back on/login?error=oauth.
For the full OAuth setup β Google provider config, redirect URLs, the difference between redirectTo and the site URL, and the Vercel-preview gotcha that breaks it β the Supabase + Google OAuth on Next.js 15 working guide covers it end to end.
Related Incidents
-
Supabase auth + middleware: complete session management guide β where the OAuth callback fits in the broader refresh chain, and why
exchangeCodeForSessionbelongs in a Route Handler, not a Client Component. -
Supabase auth redirect not working on Vercel preview deployments β the sibling bug: PKCE works locally, fails on preview because the redirect URL is
*.vercel.appbut your Supabase site URL is the production domain. -
Handle Supabase auth errors in Next.js middleware β what
AuthInvalidCodeErrorandAuthCodeExpiredErrorlook like in the callback and how to surface them without leaking state. - Stop the Supabase getSession() security warning β once the hash is gone, the next thing to harden is how you read the resulting session server-side.
Originally published at https://www.iloveblogs.blog












