Troubleshooting guide

Login works locally but fails in production: emails and callbacks

Sign-in works on your laptop. In production the login loops, the OAuth callback fails, or the verification email never shows up. 54 verified builder reports in our data describe some version of this. The cause is usually a setting that still points at development: a callback URL, a sender, a secret or an email plan.

54 verified cases11 min read Updated 8 Oct 2026How we collect cases

What you'll see

  • After signing in you are sent back to the login page, or the OAuth provider redirects to a 404 or to localhost.
  • Verification or reset emails arrive for you but not for new users, or arrive only in spam.
  • A reset or magic link says the token has expired or is invalid the first time the person clicks it.
  • Sessions vanish after a redeploy or restart, or the app returns HTTP 500 on every protected page.
  • Emails stop after a few sends, or the provider reports an SMTP timeout or an authentication failure.
  • Everything works on the platform subdomain but fails on the custom domain behind a proxy.

Why it happens

Cause 1

Callback and redirect URLs still point at development

Auth providers redirect to addresses on an allow list. Supabase's default Site URL is http://localhost:3000, and its docs say to change it to your production address because it is used for email confirmations and password resets. A redirectTo that is not on the Redirect URLs list will not match, and an OAuth app registered with the provider may have only the localhost callback.

Cause 2

The app cannot see the real host or protocol behind a proxy

Behind a reverse proxy, a prerender service or a container, the app may build callback URLs from the wrong host or scheme. Auth.js documents that it needs to trust the forwarded host header (AUTH_TRUST_HOST) behind your own proxy, and infers it automatically only on Vercel and Cloudflare Pages. A secret that is missing, or differs between instances or deployments, breaks sessions in a similar way. Auth.js calls its secret the only strictly required variable.

Cause 3

Email is still in development mode, or the host blocks SMTP

Supabase's built-in email service refuses to deliver to addresses outside the project's team, is limited to 2 messages per hour at the time of writing, and carries no delivery guarantee. Once custom SMTP is set up, a starter limit of 30 messages per hour applies until you change it. Separately, Railway turns outbound SMTP off on Free, Trial and Hobby plans, and Render free web services cannot send on ports 25, 465 or 587, so SMTP-based sign-in mail times out in production.

Cause 4

The sending domain is not authenticated

Gmail requires SPF or DKIM for anyone sending to personal Gmail accounts and wants spam rates under 0.3% in Postmaster Tools. Unauthenticated messages may be marked as spam or rejected with a 5.7.26 error. Bulk senders (more than 5,000 messages a day to Gmail) also need SPF, DKIM and DMARC, and one-click unsubscribe on marketing and subscribed mail. Sending sign-in codes and marketing mail from the same sender puts login at risk.

Cause 5

A mail scanner opens the link before the person does

Some providers fetch links in incoming mail. Supabase's docs cite Microsoft Defender Safe Links and say the confirmation URL is then consumed instantly, which produces a 'Token has expired or is invalid' error. A link that signs someone in on a plain GET request is spent by the scanner.

How to fix it

  1. Set the production URLs at the auth provider

    Change the Site URL to your production origin and add the exact production callback path to the redirect allow list. Supabase recommends the exact path over a wildcard for production; wildcard patterns for Vercel and Netlify previews belong in a separate entry. Register the production callback with the OAuth provider as well, or use a separate OAuth app per environment, as Auth.js suggests.

    await supabase.auth.signInWithOAuth({
      provider: 'github',
      options: { redirectTo: 'https://app.example.com/auth/callback' },
    })
  2. Fix the secret and the proxy headers

    Generate the session secret once and store it as a host environment variable, so every instance and every deploy uses the same value. If the app sits behind your own reverse proxy or runs in Docker, tell the auth library to trust the forwarded host. Check that the proxy forwards the Host and X-Forwarded-Proto headers and keeps the query string on rewrites.

    # generate once: openssl rand -base64 33
    AUTH_SECRET=<generated value>
    AUTH_TRUST_HOST=true
  3. Test the whole loop on the production domain with a real inbox

    Sign up, verify, sign out, reset, and sign in again on the live domain, using a Gmail address and one other provider you do not control. Do it from a clean browser profile and from a phone. Staging on a platform subdomain proves little about a custom domain or a proxy.

  4. Send from a verified domain through a production provider

    Add your own domain at the email provider and move off the built-in or testing sender. If your host blocks SMTP, use the provider's HTTPS API instead; Railway recommends this on every plan. Check the provider's bounce and suppression lists after launch, because one address that hard-bounces can quietly stop mail to that person.

  5. Publish SPF, DKIM and DMARC for the sending domain

    Add the DNS records the provider gives you, then add a DMARC record (a policy of none is accepted to start). Register the domain in Google Postmaster Tools and keep the spam rate well under 0.3%. Keep marketing mail on a separate sender or subdomain from sign-in mail.

  6. Make links survive scanners and build them from configuration

    Send a code the person types, or link to a page of yours with a confirm button, so a GET request alone does not spend the token. Supabase documents both patterns, and verifyOtp completes the code flow. Make tokens single use with a short expiry, and build the link from a configured base URL, not from the request's Host header, as OWASP advises.

    const { data, error } = await supabase.auth.verifyOtp({
      email,
      token,
      type: 'email',
    })
  7. Keep a recovery path for your own account

    Make sure at least one other working route exists into the hosting, DNS and email-provider accounts: stored backup codes for two-factor authentication, a second verified owner, and a recovery address you control. Check this before launch, because account recovery after losing a 2FA key can be impossible.

Check it's fixed

  • A sign-in or verification email sent to a fresh Gmail address from the live domain arrives in the inbox within a minute, and Gmail's 'Show original' view reports SPF, DKIM and DMARC as pass.
  • Fetch the sign-in link once with curl, then click it in a browser. If it still works, the link survives a scanner.
  • Redeploy or restart the app, then reload a signed-in page. The session is still valid.
  • Complete the OAuth flow from the production domain and from a preview URL. Both land on the intended page with no localhost in the redirect chain.

Fix it with Gemmein

Sign-in on Gemmein is by email code, and Gemmein sends that code itself: as "<App name> (via Gemmein)" until your domain is verified for email, then from that domain's account word. Sign-in codes sit outside the Development email cap, and the number of sign-in emails an app sends in a day is not capped.

  1. Sign people in with an email code

    Call g.auth.sendEmailCode with the person's address, then g.auth.verifyEmailCode with the code they enter.

    await g.auth.sendEmailCode(email);
    await g.auth.verifyEmailCode({ email, code });
    const me = await g.auth.currentUser();
  2. Read the codes locally instead of mailing them

    The local runtime prints sign-in codes to the terminal instead of sending email, so nothing leaves the machine. The same codes are also added to gemmein/.data/signin-codes.log, which starts fresh on each dev boot.

    npx gemmein dev
  3. Let Gemmein's address carry the codes until your domain is verified

    Before a domain is verified, sign-in codes are the one email Gemmein sends on your behalf, as "<App name> (via Gemmein)". They are outside the Development send budget of 20 a day per app.

  4. Add your domain for email on the Domains page

    Add the domain with a purpose of email (or both). Sign-in codes then arrive from that domain's account word.

Questions

Why does login work locally but not after I deploy?

The usual causes are a Site URL or OAuth callback that still points at localhost, a redirect URL missing from the provider's allow list, or a proxy that hides the real host and protocol from the app. Check those three first.


Why do verification emails reach me but not my users?

Built-in and testing senders often deliver only to team or account addresses. Supabase's default service refuses addresses outside the project's team. Verify your own domain and use a production email provider.


Why does my reset link say the token has expired the first time I click it?

A mail scanner probably fetched the link first and consumed the token. Use a typed code or a confirm button on your own page, so that a plain GET request does not complete the action.


Can I send sign-in email over SMTP from Railway or Render?

Not on every plan. Railway enables outbound SMTP only on Pro and above, and Render free web services cannot use ports 25, 465 or 587. An email provider's HTTPS API works on all of them.


Sources

← Back to the full report