Supabase Auth Fails After Deploying to Vercel: Redirect URLs, Cookies, Env
Founder & Lead Engineer, RAITHub
When Supabase auth works locally but fails after deploying to Vercel, the cause is almost always configuration, not code. Check four things in order: the Site URL, which defaults to http://localhost:3000; the redirect URL allow list, including a wildcard for preview deployments; session cookies refreshed in your Next.js proxy; and environment variables, which must be set for each Vercel environment and followed by a redeploy.
This guide is for teams on Next.js and Supabase whose sign-in, magic links, password resets or OAuth buttons worked on a laptop and stopped working on the live site or on preview URLs. Every setting below is from Supabase's, Next.js's and Vercel's own documentation, checked on 29 September 2026. For deploy problems beyond auth, see 12 reasons an app works locally but breaks in production.
Which symptom points to which cause?
| Symptom after deploy | Likely cause | Fix |
|---|---|---|
| Login or magic link sends users to localhost:3000 | Site URL still at its default, or no redirectTo passed | Set the Site URL to production; pass redirectTo |
| Works on production, lands on the wrong site from preview URLs | Preview domains missing from the redirect allow list | Add a Vercel wildcard redirect URL |
| Confirmation or reset emails link to the wrong domain | Email template uses {{ .SiteURL }} | Use {{ .RedirectTo }} where you pass redirectTo |
| Google or GitHub shows a redirect URI mismatch | Provider has your app URL instead of Supabase's callback | Register https://<project-ref>.supabase.co/auth/v1/callback |
| Callback page errors when exchanging the code | Link opened in another browser, reused, or older than 5 minutes | Same browser and device; request a fresh link |
| Signed in on the client, signed out in server components | Session cookies not refreshed or dropped by the proxy | Refresh in proxy.ts with getClaims(); keep the cookies |
| Supabase URL or key is undefined in the browser | Env var missing for that environment, or changed without a rebuild | Set it per environment in Vercel, then redeploy |
| A user sees someone else's session | A cached response carried a Set-Cookie header | Never cache authenticated responses at the CDN |
Why does Supabase redirect to localhost after deploying?
Because the Site URL is still the default. The Supabase redirect URLs guide says the Site URL "defines the default redirect URL when no redirectTo is specified in the code", and that its default is http://localhost:3000, which should be updated to your production domain.
- In the Supabase dashboard, open Authentication, then URL Configuration.
- Set Site URL to your production origin, such as
https://app.example.com. - Keep
http://localhost:3000/**in the Redirect URLs list so local development still works.
Magic links and emails that were already sent still carry the old URL. Test with a new one.
How do you allow Vercel preview URLs in Supabase?
Add a wildcard to the Redirect URLs list, and pass redirectTo from code that knows which deployment it is running on. The same guide says the URL in redirectTo should match the Redirect URLs list, supports * and ** wildcards, and gives this pattern for Vercel: https://*-<team-or-account-slug>.vercel.app/**.
It also gives a helper that picks the right origin per environment, using your own NEXT_PUBLIC_SITE_URL in production and Vercel's deployment URL on previews:
// lib/get-url.ts, adapted from the Supabase redirect URLs guide
export const getURL = () => {
let url =
process?.env?.NEXT_PUBLIC_SITE_URL ?? // set this in Vercel for Production only
process?.env?.NEXT_PUBLIC_VERCEL_URL ?? // Vercel's per-deployment URL
'http://localhost:3000/'
url = url.startsWith('http') ? url : `https://${url}`
url = url.endsWith('/') ? url : `${url}/`
return url
}
// Sign-in with OAuth or magic link, returning to your callback route:
await supabase.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo: `${getURL()}auth/callback` },
})
Two cautions. If NEXT_PUBLIC_SITE_URL is set for all environments, previews will redirect to production; scope it to Production only. And a wildcard across every preview is broad; if previews use a real Supabase project with real users, consider a separate project for previews instead.
Why do Supabase confirmation and reset emails link to the wrong site?
Because the email templates build their links from the Site URL. The redirect URLs guide says that when using a redirectTo option, you may need to replace {{ .SiteURL }} with {{ .RedirectTo }} in email confirmation and password reset templates. Open Authentication, then Email Templates, and check every template your app uses, not just the one you tested.
How do you fix OAuth login after deploying?
Register Supabase's callback with the provider, and your own app URL with Supabase. They are two different lists, and mixing them up is the most common OAuth mistake.
- At the provider (for example Google Cloud), the authorised redirect URI is Supabase's: the Supabase Google login guide gives
https://<project-ref>.supabase.co/auth/v1/callbackfor production. Add your site's origin under authorised JavaScript origins. - In Supabase, your app's callback route, such as
https://app.example.com/auth/callback, goes in the Redirect URLs list. - If you use a custom domain for Supabase, the provider needs that domain's callback URL instead.
Why does the code exchange fail on the callback page?
Server-side Supabase auth uses the PKCE flow: a code verifier is stored when sign-in starts, and the callback exchanges a one-time code for a session. The Supabase PKCE flow guide says the code "has a validity of 5 minutes and can only be exchanged for an access token once", and that the exchange "must be initiated on the same browser and device where the flow was started".
So a magic link requested on a laptop and opened in a phone's mail app fails, as does a link clicked twice, or an email scanner that opens links before the user does. A callback route based on the one in Supabase's Google login guide:
// 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')
let next = searchParams.get('next') ?? '/'
if (!next.startsWith('/')) next = '/' // never redirect to another site
if (code) {
const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) return NextResponse.redirect(`${origin}${next}`)
}
return NextResponse.redirect(`${origin}/auth/auth-code-error`)
}
Give the error page a clear message: "This link has expired or was opened in a different browser. Request a new one here." Supabase's own version of this route also handles the x-forwarded-host header when running behind a load balancer.
Why am I logged in on the client but logged out on the server?
Because the session cookie is not being refreshed, or is refreshed and then thrown away. The Supabase Next.js server-side auth guide puts this job in the proxy, proxy.ts in Next.js 16 and later or middleware.ts before that, which refreshes the auth token by calling supabase.auth.getClaims() and passes refreshed tokens to server components and the browser through cookies.
// proxy.ts (Next.js 16+; middleware.ts on earlier versions)
import { NextResponse, type NextRequest } from 'next/server'
import { createServerClient } from '@supabase/ssr'
export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
response = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options))
},
},
},
)
await supabase.auth.getClaims() // refreshes the session; keep it right after creating the client
return response
}
This is a minimal sketch; copy the current version from the Supabase guide, since newer @supabase/ssr releases also pass cache headers through setAll. The failures to look for:
- The proxy is not running at all. After upgrading to Next.js 16, a file still named
middleware.ts, a file in the wrong folder or a matcher that excludes your routes means no refresh. See why proxy.ts or middleware is not running. - A new response drops the cookies. If you return a redirect or rewrite, the guide says to copy the cookies, plus the
cache-control,expiresandpragmaheaders, from the Supabase response onto yours. - Trusting
getSession()on the server. The guide is explicit: "Never trustsupabase.auth.getSession()inside server code such as Proxy. It reads the session out of the cookie without revalidating it." UsegetClaims(), which verifies the token's signature. - Cached authenticated pages. The guide warns that behind a CDN or with ISR, "caching of HTTP responses can cause users to receive another user's session." Keep pages that read the session dynamic.
Why are my Supabase environment variables undefined on Vercel?
Either the variable is not set for that environment, or it was set after the build that is now live. The guide expects NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY for Next.js, and two platform rules apply to them.
- Public variables are fixed at build time. The Next.js environment variables guide says
NEXT_PUBLIC_values are inlined into the JavaScript bundle at build, and "After being built, your app will no longer respond to changes to these environment variables." - Changes need a new deployment. The Vercel environment variables docs say changes "are not applied to previous deployments, they only apply to new deployments", and each variable is scoped to Production, Preview, Development or a custom environment.
So: set both variables for every environment that needs them, check the names match exactly, including the key name if you moved from the older anon key to a publishable key, and redeploy. Server-only secrets, such as a service role or secret key, must never carry the NEXT_PUBLIC_ prefix, because that ships them to every browser. If a secret has already been exposed that way, rotate it; the steps are in what to do about exposed API keys.
How do you stop auth breaking on the next deploy?
- Write the configuration down. Site URL, redirect URLs, provider callbacks and which env vars exist in which Vercel environment, in the repo's README.
- Run an end-to-end sign-in test against the preview deployment in CI, covering email link, password and each OAuth provider you support.
- Fail the build on missing variables. Validate required env vars at start-up, so a missing key is a clear build error instead of a blank login screen.
- Keep database changes in migrations, not dashboard edits, so auth-related tables and policies match across projects; see Supabase migrations in production.
- Check row-level security once sign-in works, because a working login on a table without RLS is still an open table; see "RLS disabled in public" and how to fix it.
Why RAITHub for a broken Supabase deploy
- Deploy-environment debugging is routine work. RAITHub's own site has published fixes for failures that only appeared on Vercel, such as an npm ERESOLVE peer conflict, and verified the Next.js 16
middleware.tstoproxy.tsrename by checking that protected pages still redirect without a session. - Auth and data access done carefully. Sundor Skin runs on 146 PostgreSQL tables with row-level security, 88 permission codes and 12 staff roles.
- Fixes come with tests. A regression test for the sign-in flow is part of the fix, the same habit behind 400+ tests on this site.
- Fixed scope, your IP. A free 15-minute technical audit, then a fixed written quote. You own the code; an NDA is standard.
RAITHub has no Supabase-specific case study to point to; the steps above are engineering guidance drawn from the vendors' documentation.
When you don't need us
- The Site URL or a missing redirect URL was the cause. That is a two-minute dashboard change; the table at the top usually finds it.
- You only need the current proxy code. Copy it from the Supabase Next.js guide and compare it line by line with yours.
- The problem is inside Supabase's service. Check the Supabase status page and support channels first.
If sign-in is still failing after these checks, the fix one issue page lists what to send, and Next.js deploy work is covered by the Next.js development service. To have it looked at, book the free 15-minute technical audit and bring the exact symptom, the URL you land on and your Supabase URL Configuration screen.
Last reviewed: 29 September 2026. Documentation checked on 29 September 2026.
Frequently asked questions
Why does Supabase redirect to localhost after login in production?
The Site URL in Authentication, URL Configuration is still its default, http://localhost:3000, or your code does not pass redirectTo. Set the Site URL to your production domain and pass redirectTo explicitly.
How do I add Vercel preview URLs to Supabase redirect URLs?
Add a wildcard entry in the Redirect URLs list, in the form https://*-your-team-slug.vercel.app/**, as Supabase's redirect URLs guide shows, and build redirectTo from the deployment's own URL.
Why does the Supabase magic link fail when opened on my phone?
With the PKCE flow, the code must be exchanged on the same browser and device where sign-in started, within 5 minutes and only once. Open the link where you requested it, or request a new one.
Should I use getSession or getClaims on the server?
Use getClaims. Supabase's Next.js guide says never to trust getSession in server code such as the proxy, because it reads the cookie without revalidating it, while getClaims verifies the token's signature.
I changed an environment variable on Vercel. Why is nothing different?
Vercel applies variable changes only to new deployments, and Next.js inlines NEXT_PUBLIC_ values at build time. Redeploy after every change.
Why are users logged out on the server after deploying?
Usually the proxy is not running, or it creates a new response without copying the refreshed Supabase cookies. Check the proxy file name and matcher, and copy cookies onto any response you return.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.