Sign-in works locally but fails in production: how to fix it
Sign-in works on localhost, then fails on the live domain. The flow stops partway, reset emails never arrive or report an expired link, and signed-in users are logged out. This is one of the most common go-live failures in our data, with 15 verified builder reports across Supabase, Firebase, Next.js and app-builder stacks such as Lovable.
What you'll see
- Sign-up or sign-in never completes: the user clicks the email link and ends up on localhost, a blank page or the home page, still signed out.
- Magic links, confirmation links or reset links say "expired or invalid" on the user's first click, most often for users on corporate mail.
- Sign-in and reset emails never arrive, or land in spam, even though the provider logs them as sent.
- The browser shows the user signed in, but server-rendered pages or API routes treat them as logged out.
- Users are signed out mid-session, often when they have two tabs open or several requests run at once.
- A paying customer signs in and no longer has access to what they paid for.
Why it happens
Redirect URLs still point at development
Email links and OAuth callbacks redirect to a URL your auth provider must allow. In Supabase, the Site URL is the default redirect when no redirectTo is given, and any redirectTo must match an entry in the Redirect URLs allow-list. In Firebase, the domain must be listed under Authorized domains. If the production origin is missing, or the Site URL is still localhost, the link lands in the wrong place or is refused.
Link scanners use up one-time links
Some mail security products follow every link in an incoming email to scan it. Microsoft Defender for Office 365 Safe Links is one example. The scanner opens the one-time link first and consumes the token, so the user's own click fails. This rarely shows up in testing, because developers rarely test with a corporate inbox.
Cookie attributes that only work on one host
A cookie set without a Domain attribute is host-only, so it is not sent to subdomains such as api.example.com. SameSite=Lax cookies are not sent on cross-site POST requests. SameSite=None is only valid together with Secure, which requires HTTPS. Firebase's signInWithRedirect also breaks in browsers that block third-party storage, unless you apply one of its documented workarounds.
Refresh-token rotation races
With rotation, each refresh token can be exchanged only once. Supabase allows a 10-second reuse interval and recommends leaving it at that. When two tabs, or a server render and the browser, refresh the same token independently, one of them can end up holding a revoked token, and the user is signed out. In Next.js with Supabase, Server Components cannot write cookies, so the middleware or proxy has to refresh the token and pass it on to the page and the browser.
Access keyed to the wrong identity
Paid access is often stored against an email address, or recorded by a checkout that was never tied to a user ID. If the user changes their email, signs in with a different method, or pays in a session the webhook can't match, sign-in works but the access check finds nothing. Separately, if a CDN caches a response that sets auth cookies, it can serve one user's session to another.
How to fix it
Put the production origin on every allow-list
Set the Supabase Site URL to your production URL and add each real callback URL, including preview domains if you use them, to Redirect URLs. In Firebase, add the domain under Authentication settings, Authorized domains, and remove localhost from the production project.
Stop scanners from using up one-time links
Have the user type a one-time code instead of clicking a token link, or send them to a page on your site with a button that submits the token only when a person clicks it. Supabase documents both options.
const { data, error } = await supabase.auth.verifyOtp({ email, token, // the code the user typed type: 'email', });Authenticate the sending domain
Gmail requires every sender to have SPF or DKIM, and bulk senders to have SPF, DKIM and DMARC. Set up all three with your email provider, and don't send production mail through a provider's built-in test mailer. Supabase's default service only delivers to pre-authorized addresses and is rate limited.
dig +short TXT example.com | grep spf1 dig +short TXT <selector>._domainkey.example.com dig +short TXT _dmarc.example.comSet cookie attributes deliberately
Decide whether the session cookie should be host-only or shared across subdomains, and set Domain to match. Use Secure and HttpOnly. Use SameSite=None; Secure only when you need cross-site requests. Mark responses that set auth cookies as uncacheable so a CDN never stores them.
Set-Cookie: session=...; Path=/; Secure; HttpOnly; SameSite=Lax Cache-Control: private, no-storeMake token refresh single-flight
Within one page, share one in-flight refresh promise. Across tabs of the same origin, use the Web Locks API so only one tab rotates the token at a time. On the server, refresh in one place (in Next.js with Supabase, the middleware or proxy) and verify with getClaims() rather than trusting getSession().
let refreshing: Promise<Session> | null = null; export function refreshOnce(): Promise<Session> { refreshing ??= navigator.locks .request('auth-refresh', () => doRefresh()) .finally(() => { refreshing = null; }); return refreshing; }Tie paid access to a stable user ID
Store access against the auth provider's user ID, never the email address. With Stripe Payment Links, append client_reference_id=<user id> to the link. Stripe returns it on the checkout.session.completed webhook, so you can grant access to the right account.
https://buy.stripe.com/<link>?client_reference_id=<user_id>Log every auth failure with a reason
Log each sign-in, refresh and access-check failure with a specific reason, the route, and the user ID when you have it. Never log passwords, access tokens or session IDs. If you need to follow one session across requests, log a hash of its ID.
{"event":"auth_failure","reason":"refresh_token_already_used","route":"/api/me","user_id":"u_123","session_hash":"9f2c..."}
Check it's fixed
- On the production domain, in a fresh private window, sign up with a new address at a corporate mailbox that uses link scanning, and at a consumer mailbox such as Gmail. Complete sign-in and a password reset from each.
- Open the app in two tabs and leave both open past the access-token lifetime. Both should stay signed in, and your logs should show no reused-token errors.
- Inspect the Set-Cookie headers on production with browser devtools or curl -sI, and confirm Domain, Secure, HttpOnly, SameSite and Cache-Control are what you intended.
- Buy with a test account, change that account's email, sign in again, and confirm access is still granted.
Gemmein sign-in uses one-time 8-digit email codes only. There are no passwords and no social or OAuth sign-in, so there is no password database and no reset flow to break. Codes last 10 minutes, sessions last 30 days, and each person gets one session. Codes go out from your own verified domain, or as "<App name> (via Gemmein)" until that domain is verified. Your own server can check a session from any host with verifySession(token). The trade-off: Gemmein refuses password and social sign-in on purpose, so if your product needs either, it is the wrong tool.
Questions
Why does my magic link say it has expired the first time a user clicks it?
Most likely the user's mail security scanner opened the link first and consumed the one-time token. Switch to a typed code, or to a page with a confirmation button that submits the token only when a person clicks it.
Why is the user signed in on the client but logged out on the server?
The server isn't receiving a valid, refreshed session cookie. The usual cause is a cookie Domain or SameSite mismatch, or no single place that refreshes the token. In Next.js with Supabase, the middleware or proxy refreshes the token and writes the cookie, and server code should verify with getClaims().
Why do users get logged out when they open a second tab?
Both tabs try to rotate the same refresh token. One succeeds and the other's token is revoked. Make the refresh single-flight across tabs, for example with the Web Locks API.
Do I need SPF, DKIM and DMARC for sign-in emails?
Gmail requires every sender to have SPF or DKIM, and bulk senders to have all three. Sign-in mail has to arrive, so set up all three on the sending domain.
Sources
- Supabase: Redirect URLs
- Supabase: Email templates (email link prefetching)
- Supabase: User sessions (refresh token reuse)
- Supabase: Setting up server-side auth for Next.js
- MDN: Set-Cookie header
- Firebase: Best practices for signInWithRedirect on browsers that block third-party storage
- Google Workspace: Email sender guidelines
- OWASP Logging Cheat Sheet