Login works locally but breaks in production
Localhost and the public site are different origins. A session cookie, an OAuth client, and a magic link that were set up for http://localhost:3000 stay on your laptop when you deploy. The password can be right. The production host still has to set the cookie, and it has to be on the provider's allow list. This is the localhost case for apps built with Lovable, Bolt, Cursor, and Replit. If login works on a Bolt preview and fails only on the published URL, the Supabase redirect list is the other page, linked below.
What you see
- The same email and password sign you in on localhost. On the deployed site the form returns to login, the console shows a CORS error, or a reload logs you out.
- After submit on production, the address bar goes to http://localhost:3000, or Google or GitHub shows redirect_uri_mismatch.
- The confirmation or reset email links to localhost. Opening it on your phone does not reach the deployed app.
- On the production host the Application tab shows no session cookie, or the cookie's Domain is localhost. The localhost tab has a cookie. That cookie is never sent to the other host.
- The browser console on production says the login request was blocked by CORS, and the request host is not the page host. Locally there is no such error, because the dev server proxied /api onto the same origin.
Why this happens
- The auth library's public URL is still http://localhost:3000 in the production environment, so callbacks and email links leave the site.
- The session cookie was set for localhost. Cookies do not move to the host you deployed.
- The OAuth app's redirect list includes localhost and was never updated with the production callback.
- In dev the UI and the API shared an origin through a proxy. In production they are two hosts, and the cookie and CORS rules were written for one.
Common causes
The public origin in the environment is still localhost
Auth.js reads AUTH_URL. Older Auth.js setups also read NEXTAUTH_URL. Better Auth reads baseURL or BETTER_AUTH_URL. A hand-rolled mailer often reads APP_URL or SITE_URL. When that value is http://localhost:3000 on the host, the redirect after login and the link in the email point at your laptop. The production form can still submit.
How to check. Read the production environment, not the .env on your laptop. The value is the https origin users type, with no path and no localhost. A Vite or Next client that inlined the old URL keeps it until you build again. Change the value where the publish step builds, then build.
The session cookie belongs to localhost
A cookie set by localhost is stored for localhost. Production never receives it. Domain=localhost, or a Domain copied from a tutorial, scopes the cookie to the wrong host. A cookie marked Secure is stored on HTTPS and skipped on plain HTTP, which is what you want on the deployed site. Opening a production build over http://localhost will look logged out for that reason.
How to check. On the deployed site, after the login response, open the Application tab. The cookie's host is the deployed host. Domain is empty (host-only) or that host. Path is /. Secure is set because the site is HTTPS. SameSite is Lax for a normal same-site form. A missing cookie means the response did not set one, or the browser rejected it.
The OAuth client only allows localhost
Google and GitHub keep their own allow list, separate from your app and separate from Supabase. A redirect URI of http://localhost:3000/api/auth/callback/google does not cover https://your-app.example/api/auth/callback/google. The provider answers with redirect_uri_mismatch.
How to check. In the Google Cloud OAuth client, or the GitHub OAuth app, read Authorized redirect URIs. For Google, also read Authorized JavaScript origins. The production origin and the exact callback path are listed. The scheme and the host are part of the match.
Production split the UI and the API
Vite's dev server proxies /api to a local port, so the browser talks to one origin. After deploy, the page is on one host and the API is on another. A fetch that omits credentials does not send the cookie. A response of Access-Control-Allow-Origin: * cannot be combined with credentials. The browser reports a CORS error and stores nothing.
How to check. In the network tab, compare the page host with the login request host. When they differ, read the response headers. Allow-origin is the page's origin, not *. Allow-credentials is true. The request is sent with credentials.
Supabase Site URL is still localhost
Supabase Auth has its own Site URL and redirect allow list. When Site URL is http://localhost:3000, confirmation and reset links go to your laptop. When the deployed origin is missing from Redirect URLs, the callback stops at Supabase. The Bolt preview case, where the preview works and the published URL does not, is a separate page.
How to check. Open Authentication, then URL configuration, in the Supabase project the production env points at. Site URL is the deployed origin. That origin is in Redirect URLs. The project ref in the production Supabase URL matches the project where this user was created.
The account exists only in the local database
A user created against a local Postgres, a local Supabase project, or a SQLite file is not a user in the database the deploy uses. The form posts. The lookup finds no row. The UI says the credentials are invalid.
How to check. In the database named by the production environment, look up the email. If the row is missing, create the user there the same way the app signs people up. Confirm the host is not still pointing at a local connection string.
Fix it
1. Set the public origin, then rebuild
Use the origin only, with https, on the host that serves the app. Set the name your library actually reads. AUTH_URL covers current Auth.js. NEXTAUTH_URL covers older setups. APP_URL covers a link you build yourself. If the client bundle inlines a VITE_ or NEXT_PUBLIC_ copy of this URL, set it where the publish step builds and build again. Restarting the previous build keeps the old string.
AUTH_URL=https://YOUR_PRODUCTION_HOST APP_URL=https://YOUR_PRODUCTION_HOST
2. Set the cookie for the deployed host
Leave domain unset. A host-only cookie is sent only to the host that set it. secure follows the environment so an HTTPS deploy stores the cookie and a local HTTP server can still store one. SameSite=Lax fits a form that posts on the same site. Test this on the https URL. A production build opened over http://localhost will refuse a Secure cookie, which looks like a login bug and is the browser dropping it.
const secure = process.env.NODE_ENV === "production";
cookieStore.set("session", token, {
httpOnly: true,
secure,
sameSite: "lax",
path: "/",
});3. Allow the production callback at Google or GitHub
Add the production callback next to the localhost one. Keep localhost if you still sign in from your machine. The path has to match the route your app uses. Google also wants the production origin under Authorized JavaScript origins. GitHub's homepage URL can stay as the deployed origin.
http://localhost:3000/api/auth/callback/google https://YOUR_PRODUCTION_HOST/api/auth/callback/google
4. Allow the page origin when the API is a second host
Skip this step when the login request goes to the same host as the page. When it does not, the API has to echo that origin and allow credentials. A star origin does not work with credentials. The browser fetch from the page sets credentials to include. Vary: Origin keeps a cache from reusing one origin's response for another.
const origin = request.headers.get("origin");
if (origin && origin === process.env.APP_URL) {
headers.set("access-control-allow-origin", origin);
headers.set("access-control-allow-credentials", "true");
headers.set("vary", "origin");
}
await fetch(apiUrl + "/login", {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password }),
});5. Create the user in the database the deploy uses
Sign up once on the deployed URL, or insert the test account with the same path the app uses for signup, against the production database. A user that exists only on localhost will keep failing the production form after the cookie and the redirect URL are fixed. For Supabase, this is the project whose URL is in the production env, and Site URL for that project is the deployed origin.
How to verify
- Use a private window on the https production URL. Leave the localhost tab closed. A localhost session cannot prove the deploy works.
- Sign in with a user that exists in the production database. Reload. The address bar stays on the production host. The Application tab shows a session cookie for that host.
- If you use Google or GitHub, the callback URL's host is the production host, and the provider does not answer with redirect_uri_mismatch.
- Open the confirmation or reset email and read the host before you click. It is the production host.
- A second browser with no cookie is logged out. The cookie is scoped to the deployed host, which is what you want.
How the daily check catches it
The daily check starts on the saved URL, with no leftover localhost cookie. It creates or reuses the test account and logs in. A form that errors, sends the browser to localhost, or never ends on a logged-in page fails the run.
Related guides
- Bolt.new app login broken after deploy. Login works on the Bolt preview and fails on the published URL. That page covers the Supabase redirect allow list and a published build that never received the anon key.
What BearWatch looks for
- BearWatch opens the URL you saved. Save the deployed https URL. The monitor cannot open localhost on your laptop.
- It submits the test account you stored. An error, a redirect to localhost, or a page that is still logged out fails the login step.
A public look, before the daily check
The free roast reads the public homepage and checks that a login link is there, and that keys are not sitting in the JavaScript. The free scan looks for secret keys and for tables the anon key can read. Neither one types a password. They show what the deployed page is already exposing while you fix the cookie and the redirect. The daily check is the run that logs in.
Prompt to paste
Login works on localhost and fails on the deployed site. Set AUTH_URL, NEXTAUTH_URL, BETTER_AUTH_URL, or APP_URL to the public https origin and rebuild if the client inlined it. Set the session cookie for that host, with Secure on HTTPS, and without Domain=localhost. Add the production callback to the Google or GitHub OAuth client. If the API is a different host, allow the page origin with credentials and do not use Access-Control-Allow-Origin *. For Supabase, set Site URL and the redirect allow list to the deployed origin. Create the test user in the database the deploy uses.