Supabase Auth on Vercel Requires Double Login in Production

I’m experiencing a double-login issue on Vercel production that does not occur on localhost.

Localhost Behavior (Expected)

  • User clicks “Sign in with Google”

  • OAuth redirect → /auth/callback?code=... → session established

  • User is immediately logged in and can access protected routes

  • Single login, works every time

Vercel Production Behavior (Broken)

  • User clicks “Sign in with Google”

  • OAuth redirect → /auth/callback → appears to land successfully

  • User is NOT logged in on first attempt

  • Must click “Sign in” again (sometimes 2–3 times) for session to persist

  • After multiple attempts, the session eventually “sticks”

  • Sometimes logs out again after refresh

Additional Symptoms

  • Auth cookies (sb-access-token, sb-refresh-token) appear to be set but not recognised immediately

  • Refreshing right after callback sometimes logs the user out

  • API routes return 401 Unauthorized even after apparent successful login

  • Clearing cookies temporarily resets the issue


What I’ve Tried

  1. Investigated Edge Runtime cookie propagation

  2. Added a temporary delay page after callback (didn’t fix it)

  3. Disabled middleware completely — issue persisted

  4. Added runtime = 'nodejs' to callback — no change

  5. Reviewed cookie handling inside middleware — saw repeated NextResponse.next() creation

  6. Rolled back to an older commit to remove recent auth refactors

None of these resolved the issue on Vercel.

Environment

Framework: Next.js 15.5.3 (App Router)

Deployment: Vercel (production)

Auth: Supabase Auth with `@supabase/ssr` v2

Auth Flow: OAuth (Google) + Magic Link

Runtime: Currently mixed (some routes use `runtime = ‘nodejs’`, others default to Edge)

Current Setup

middleware.ts

import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabaseMiddleware'

export async function middleware(request: NextRequest) {
  return await updateSession(request)
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\.(?:svg|png|jpg|jpeg|gif|webp)|api).*)',
  ],
}

lib/supabaseMiddleware.ts

export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({ request })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          )
          supabaseResponse = NextResponse.next({ request }) // 🔴 Recreates response
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  const { data: { user } } = await supabase.auth.getUser()

  return supabaseResponse
}

src/app/auth/callback/route.ts

export const dynamic = 'force-dynamic'

export async function GET(request: Request) {
  const url = new URL(request.url)
  const next = url.searchParams.get('next') ?? '/'

  const cookieStore = await cookies()
  const supabase = createServerClient(/* ... */)

  await supabase.auth.exchangeCodeForSession(code)
  const { data: { session } } = await supabase.auth.getSession()

  if (!session) {
    return NextResponse.redirect(new URL('/login?error=auth_failed', origin))
  }

  const successRedirect = NextResponse.redirect(new URL(next, origin))
  cookieStore.getAll().forEach(({ name, value, ...options }) => {
    successRedirect.cookies.set({
      name,
      value,
      path: '/',
      sameSite: 'none',
      secure: true,
      ...options,
    })
  })

  return successRedirect
}

Supabase Server Client Structure (lib/supabaseServer.ts)

  • createSupabaseServerClientForComponent() → no-op setAll

  • createSupabaseServerClientForRoute() → writes cookies with sameSite: 'none'

  • createSupabaseServerClientForHandler() → same as above
    What I Need Clarification On

    Since I’m back on my older commit (stable before refactors), I want to understand the correct, production-safe setup for Supabase SSR on Vercel:

    1. Should I use middleware or not?

    Supabase docs show both patterns:

    • With updateSession() middleware (edge-based)

    • Without middleware (server-component only)

    But middleware runs on Edge runtime, and Supabase SSR docs mention limitations on Edge.

    Which approach is correct for Vercel today?

    2. Should I explicitly force runtime = "nodejs" on:

    • /auth/callback

    • Server component pages

    • API routes

    • Globally in next.config.js

    Is mixing edge/node causing the stale session?

    3. Are there required files for a stable setup?

    Should my project include:

    • middleware.ts?

    • Only a callback route?

    • Only server clients?

    • Both middleware + server clients?

    What is the recommended minimal setup?

    4. Is this a known issue with Supabase SSR on Vercel?

    I’ve seen a few posts mentioning:

    • Multi-region cold starts

    • Cookie propagation delay

    • Callback and next SSR request hitting different function instances

    • Edge isolation issues

    Would love to know if this aligns with known behavior.

    If anyone has a working, clean example:

    A working repo link or file structure example (auth flow + SSR sessions) would help massively.

    Thank you

    I’ve been stuck on this for weeks and would love clarity from anyone who has solved this in production or from the Vercel/Supabase team.
    Happy to share logs, repo snippets, or deploy URLs if needed.

    Thanks in advance!

This topic was automatically closed 180 days after the last reply. New replies are no longer allowed.