Supabase Auth Callback Redirect Not Working? Next.js Fix
Auth redirect not working after Supabase sign-in? Here are the three root causes and the exact fixes — callback route, redirect URL allowlist, and router.refresh().
Tested environment#
| Field | Value | |---|---| | Next.js | 14 + 15 (App Router) | | @supabase/ssr | 0.4+ | | @supabase/supabase-js | 2.x | | Runtime | Node.js (Vercel serverless) | | Last verified | 2026-06-19 | | Verification level | docs-grounded · code-reviewed — verified against Supabase Auth docs and Next.js App Router docs; not claimed as production-tested |
Failure signature#
You're hitting this issue if one or more of these is true after sign-in:
- Redirect works locally but fails on Vercel — user stays on login page or gets a Supabase error page
- OAuth callback URL not allowed — Supabase returns
redirect_uri_mismatchor a generic auth error - User confirms email but session is missing — redirected somewhere but
getUser()returns null - OAuth flow completes but app stays logged out — session cookie not written
- Middleware redirects in a loop — user gets bounced between
/loginand a protected route - Wrong
redirectToURL — user lands on a 404 or the wrong page after OAuth router.push()navigates but Server Components still show logged-out state — missingrouter.refresh()
Fast diagnosis#
Open the Network tab and sign in. Then match your symptom:
| What you see in DevTools | Likely cause | Jump to |
|---|---|---|
| /auth/callback returns 404 | Missing callback route | Fix 1 |
| /auth/callback returns 302 to Supabase error page | Redirect URL not allowlisted | Fix 2 |
| /auth/callback returns 200 but page shows logged-out | Missing router.refresh() | Fix 3 |
| OAuth sign-in never redirects back to your app | Wrong redirectTo in signInWithOAuth() | Fix 2 |
| Middleware redirect loop | Auth route not excluded from protected matcher | Fix 4 |
Why this happens#
Supabase uses a PKCE flow for OAuth and magic links in Next.js App Router:
- User clicks "Sign in with Google".
- Supabase redirects to Google with a code challenge.
- Google redirects back to your callback URL with a
codeparameter. - Your callback route calls
supabase.auth.exchangeCodeForSession(code). - Supabase sets a session cookie.
- Your callback route redirects to the app.
If step 3 uses a URL not in your Supabase allowlist, Supabase rejects it before step 4 ever runs. If step 4 is missing (no callback route), the code expires and the session is never created.
For email/password: the session is set immediately in the browser, but Next.js Router Cache keeps serving the old server-rendered HTML — the page looks logged out even though supabase.auth.getUser() would return a user. router.refresh() forces Next.js to re-fetch the Server Components with the new session.
The fixes#
Fix 1: Create the /auth/callback route#
This route is required for OAuth and magic links. Without it, the PKCE code exchange never runs.
// app/auth/callback/route.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { NextResponse, type NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
const requestUrl = new URL(request.url)
const code = requestUrl.searchParams.get('code')
const token_hash = requestUrl.searchParams.get('token_hash')
const type = requestUrl.searchParams.get('type') as 'email' | 'recovery' | null
const next = requestUrl.searchParams.get('next') ?? '/dashboard'
const cookieStore = await cookies()
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
},
},
}
)
if (code) {
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(new URL(next, request.url))
}
}
// Handle email confirmation token_hash
if (token_hash && type) {
const { error } = await supabase.auth.verifyOtp({ token_hash, type })
if (!error) {
return NextResponse.redirect(new URL(next, request.url))
}
}
return NextResponse.redirect(
new URL('/login?error=Could not authenticate user', request.url)
)
}Fix 2: Allowlist your redirect URL#
Go to Supabase Dashboard → Authentication → URL Configuration → Redirect URLs and add:
# Local development
http://localhost:3000/auth/callback
# Production
https://your-domain.com/auth/callback
# Vercel preview deployments (wildcard)
https://*-yourproject.vercel.app/auth/callbackThen in your sign-in code, point redirectTo to the callback route:
// OAuth
await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback?next=/dashboard`,
},
})
// Magic link
await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback?next=/dashboard`,
},
})Fix 3: Call router.refresh() after router.push()#
For email/password auth, the session is set client-side but Next.js does not automatically re-fetch Server Components. You must call both:
'use client'
import { createBrowserClient } from '@supabase/ssr'
import { useRouter } from 'next/navigation'
export function LoginForm() {
const router = useRouter()
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const { error } = await supabase.auth.signInWithPassword({
email: formData.get('email') as string,
password: formData.get('password') as string,
})
if (error) {
// handle error
return
}
// ✅ Both are required: push navigates, refresh re-fetches Server Components
router.push('/dashboard')
router.refresh()
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button type="submit">Sign in</button>
</form>
)
}Fix 4: Stop the middleware redirect loop#
If middleware protects /dashboard but also intercepts /login or /auth/callback, you get a loop. Exclude auth routes from the session check:
// middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
// Skip session check for auth routes — they handle their own redirects
const { pathname } = request.nextUrl
if (pathname.startsWith('/auth/') || pathname === '/login' || pathname === '/signup') {
return NextResponse.next()
}
let response = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
)
},
},
}
)
const { data: { user } } = await supabase.auth.getUser()
if (!user && pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return response
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
}Verify the fix#
- Check Supabase redirect URL list — Dashboard → Auth → URL Configuration → Redirect URLs. Confirm your production URL and any Vercel preview pattern are listed.
- Check
.env/ Vercel env vars —NEXT_PUBLIC_SUPABASE_URLandNEXT_PUBLIC_SUPABASE_ANON_KEYmust be set for the right environment (Preview vs Production in Vercel settings). - Test the OAuth flow — click "Sign in with Google", confirm you land on
/auth/callback, then on yournexttarget. - Check cookies after callback — DevTools → Application → Cookies → your domain. You should see
sb-*-auth-tokencookies after a successful callback. - Test email/password — sign in and watch the Network tab. Confirm
/dashboardis loaded fresh (not from cache) and Server Components show the logged-in state.
Production notes#
- Trailing slashes — if your Vercel project is configured with
trailingSlash: true, add the slash in the Supabase redirect URL:/auth/callback/. - Preview deployments — each Vercel preview has a unique URL. Use the wildcard pattern
https://*-yourproject.vercel.app/**in Supabase to cover all previews at once. - Wildcard risk — a wildcard
https://*would allow any domain. Always scope to your own project subdomain. NEXT_PUBLIC_SUPABASE_URL— this is the URL of your Supabase project API, not your app URL. It should end in.supabase.co.NEXT_PUBLIC_SUPABASE_ANON_KEY— the anon key is safe to expose client-side. The service role key is not.- Custom SMTP for email confirmation — if you use custom SMTP, the confirmation email uses the redirect URL from your Supabase project settings, not from code. Update it there.
Related fixes#
- Fix Supabase Auth Session Not Persisting After Refresh — session lost on page reload or in Server Components
- Handle Supabase Auth Errors in Next.js Middleware — middleware auth pattern and error handling
- Supabase Email Confirmation Not Working Fix — email confirmation token flow
- Next.js Vercel Env Variables Not Working Fix — env var scoping on Vercel
- Start Here: Debug Next.js + Supabase Production Bugs — full debugging checklist by symptom
- Next.js + Supabase Production Debugging Checklist — pre-deploy checklist
Frequently Asked Questions
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Continue Reading
Supabase Auth Session Missing? Fix for Next.js (2026)
Supabase auth sessions mysteriously disappearing after page refresh? Learn the exact cause and fix it in 5 minutes with this tested solution.
Supabase Auth Errors in Middleware: 5 Fixes That Work
Auth errors crashing your Next.js middleware? Learn how to handle Supabase auth errors gracefully with proper error handling patterns.
Supabase Email Confirmation Not Sending: 5 Fixes (2026)
Email confirmations not sending from Supabase? Learn the exact causes and fixes for SMTP, template, and configuration issues in 10 minutes.
Browse by Topic
Find stories that matter to you.
