Cenk Kurtoğlu©26

Next.js + Supabase · 7 minute read

Supabase auth on Vercel: the double-login fix

The user signs in with Google, lands on the dashboard, hits refresh - and is logged out. They sign in again, it works, they refresh, logged out again. Every single time. The cookie is in the browser the whole time. The middleware never passes it through.

Why it happens

Supabase SSR stores the session in a cookie named sb-<project-ref>-auth-token and expects the server to read it from the request and write a refreshed one back to the response. Vercel middleware runs at the edge and by default creates a Supabase client with no cookie handling at all: it does not read the request cookie, so the refreshed cookie is never written to the response. The token sits in the browser, expires, and the next request looks anonymous.

The fix: complete middleware.ts

Copy this into middleware.ts at your project root:

import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function middleware(request: NextRequest) {
  let response = NextResponse.next({ request: { headers: request.headers } })

  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, options }) => {
            response.cookies.set(name, value, {
              ...options,
              sameSite: 'lax',
              secure: process.env.NODE_ENV === 'production',
              path: '/',
            })
          })
        },
      },
    },
  )

  await supabase.auth.getUser()

  const { data: { user } } = await supabase.auth.getUser()
  const protectedPaths = ['/dashboard', '/settings', '/admin']
  const isProtected = protectedPaths.some((path) =>
    request.nextUrl.pathname.startsWith(path),
  )

  if (isProtected && !user) {
    const redirectUrl = new URL('/login', request.url)
    redirectUrl.searchParams.set('redirectTo', request.nextUrl.pathname)
    return NextResponse.redirect(redirectUrl)
  }

  return response
}

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

Why each part matters

Common mistakes that keep the bug alive

Test it

Deploy to a Vercel preview, sign in, refresh five times, then open the preview URL in an incognito window and repeat. In the browser's network tab, confirm the auth cookie is present on every request. Zero logouts on refresh is the success criteria - a single logout means the response cookie is not being written on at least one path.

The logout redirect loop this bug also causes

A /logout route that calls signOut() and then redirects to /login loops forever when middleware protects the login redirect: the cookie is cleared, middleware runs, sees no session, redirects again. Exclude the auth routes from the matcher, or handle logout in a route handler that clears the cookies explicitly:

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

Reference

If you want the whole auth surface checked by hand

I run a fixed-scope read-only review of Supabase auth configuration, server/client session boundaries, RLS and grants together - including the cookie and middleware issues this article covers. See the review scope and pricing.

Template bundle

Review all 20 frontend starters in one bundle

20 editable frontend starters with live demos are $79 — one payment, no subscription.

View the Bundle — $79↗