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 61 verified builder reports across Supabase, Firebase, Next.js and app-builder stacks such as Lovable.

61 verified cases14 min read Updated 9 Nov 2026Last reviewed 9 Nov 2026How we collect cases

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

Cause 1

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.

Cause 2

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.

Cause 3

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.

Cause 4

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.

Cause 5

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 does not store 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, not 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

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

Specific fixes

Stuck on one exact error? These pages go deeper on one symptom each.

Fix it with Gemmein

On Gemmein, members' sessions are bearer tokens the SDK keeps in the page's own storage, never cookies. The Live app key works only on the domains you have verified on the Domains page, and a browser call from any other address is refused 403 origin_not_allowed, naming the address.

  1. Add your domain from the start

    If there is a domain you want the app on, add it from the start with npx gemmein domains add <domain> (your human approves it in the browser) or on the Domains page, even in Development: domains are per app, so its records and proof carry to Live. A web domain is one record to publish, and npx gemmein domains prints each domain, whether it is verified, and every DNS record with its status.

    npx gemmein domains add <domain>
    npx gemmein domains
  2. Switch the live build to the Live key

    The Live app key (pk_live_) works only on verified domains and refuses local pages; a browser call from an address that is not a verified domain is refused 403 origin_not_allowed, naming the address, and Errors lists such addresses (up to five shown). A live site on a verified domain that still calls with the Development key is answered as normal, but Live's Errors page says so once a day per domain, code dev_key_on_live_site.

    npx gemmein keys app
  3. Keep previews and local work on the Development key

    The Development app key (pk_test_) works from any secure (https) address with nothing to list, such as a builder's preview address (Lovable, Bolt, v0, Replit), a host's preview deploy or your own domain, before and after go-live; over plain http it works only on localhost and 127.0.0.1. No other local name ever works (not app.localhost), no raw IP address ever works (https or not), and neither does a page with no address (a sandboxed frame or a file); npx gemmein dev on your computer accepts localhost only.

  4. Sign in with the emailed code and route sign-out from one place

    g.auth.sendEmailCode(email) emails an 8-digit code that g.auth.verifyEmailCode({ email, code }) checks, and a sign-in adds a session (up to 10 per person; the 11th signs the oldest out). g.auth.onSignedOut fires when the session ends however it ended, with reason "signed_out", "revoked" or "expired".

    await g.auth.sendEmailCode(email);
    await g.auth.verifyEmailCode({ email, code });
    g.auth.onSignedOut(({ reason }) => showSignIn(reason));
Read the docs: docs.gemmein.com/auth →Development sends at most 100 sign-in code emails a day per app (test members' codes don't count), after which the start answers 429 development_capped; Live has no daily cap.

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

Last reviewed 9 Nov 2026. What changed: 1 line(s) reworded. Checked against: supabase.com, supabase.com, supabase.com, supabase.com, developer.mozilla.org, firebase.google.com, support.google.com, cheatsheetseries.owasp.org. How we check this

← Back to the full report