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
getAll()reads the session cookie from the incoming request. Without it the client never sees the existing session.setAll()writes the refreshed cookie onto the outgoingresponse. This is the half that is almost always missing - and the reason the bug exists.sameSite: 'lax'works on both the custom domain and*.vercel.apppreviews.sameSite: 'none'requiressecure: trueand same-site, and breaks the moment a preview deployment serves the app from a different origin.secure: process.env.NODE_ENV === 'production'keeps preview deployments on HTTP working.await supabase.auth.getUser()is what actually refreshes the session and extends the cookie expiry. Skip the await and the refresh never happens.
Common mistakes that keep the bug alive
createServerClient(url, key)with no cookies option at all - reads nothing, writes nothing.- Only
getAllwithoutsetAll- the session is seen but never refreshed. sameSite: 'none'withsecure: false- rejected by the browser on preview.- A matcher that covers only
['/dashboard']- /settings, /admin and API routes stay unprotected and slowly drift out of sync.
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
- The middleware pattern above plus the auth/session checks I run as part of a pre-launch review are documented in my public repo: github.com/cekuu35/supabase-rls-leak-demo.
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.