DEV Community

Cover image for Supabase OAuth access_token hash Stuck in URL
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on Originally published at iloveblogs.blog

Supabase OAuth access_token hash Stuck in URL

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
Enter fullscreen mode Exit fullscreen mode

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,
      },
    },
  )
}
Enter fullscreen mode Exit fullscreen mode

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`,
  },
})
Enter fullscreen mode Exit fullscreen mode

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`)
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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 Incidents


Originally published at https://www.iloveblogs.blog

Top comments (0)