← Back to Fixes

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:

plaintext
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.

PKCE moves the token exchange server-side. The browser only ever sees a single-use authorization code in the URL, not the tokens themselves.

typescript
// 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:

typescript
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:

typescript
// 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:

tsx
// 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#

  1. Sign in with OAuth in an incognito window.
  2. After redirect, the address bar must read https://yourapp.com/dashboard — no #access_token=….
  3. Open DevTools → Network → click any outbound request to a third-party domain. The Referer header must be clean.
  4. 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 exchangeCodeForSession return auth_invalid_code and 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 fixes & guides