Supabase OAuth access_token hash Stuck in URL
After Supabase OAuth sign-in, access_token and refresh_token hash fragments stay in the URL. Here is why and how to strip them with PKCE.
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=bearerThe 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.
Related fixes & guides
- Supabase Edge Function BOOT_ERROR: Fix "Failed to Bootstrap"
- Sign In to Supabase Without Email Verification (2026)
- Supabase "Database Error Saving New User" Trigger Fix
- GraphQL Integration with Next.js and Supabase Guide
- Complete Guide to Building SaaS with Next.js and Supabase
- Supabase Auth vs Clerk in 2026: The Production Verdict
- Supabase getClaims() vs getSession(): The Silent Auth Bug
- Supabase bucket RLS policy for table objects fix
- Supabase vs Firebase in 2026: The Honest Comparison
- Why Developers Switch from Firebase to Supabase in 2026