Research / Guides

Troubleshooting guide

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.

15 verified cases in our dataUpdated 2026-09-26How we collect cases

What you'll see

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

  1. 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.

  2. 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',
    });
  3. 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.com
  4. Set 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-store
  5. Make 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;
    }
  6. 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>
  7. 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

With Gemmein

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.

Read more on gemmein.com →

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

Related guides

← Back to the full report: what breaks when AI apps go live