Fix · Sign-in works locally but fails in production: how to fix it

Login works locally but session cookie not saved in production

Sign-in works on localhost, but in production the browser drops the login cookie, OAuth needs a second attempt, or middleware sends signed-in users back to the login page. The usual cause is a cookie attribute or hosting split that localhost tolerates: an API on a different site from the frontend, or a Secure cookie behind a reverse proxy.

Likely causes, most common first

Cause 1

Frontend and API are different sites in production

Locally, localhost:3000 and localhost:8080 count as one site because the port is ignored when determining the site. In production the two often sit on different registrable domains. A SameSite=Lax cookie is then not sent with cross-site fetch() calls, and a cookie set in a CORS response is subject to third-party cookie policies. A cookie the API sets without a Domain attribute is also sent back to the API host only, so middleware on the frontend host sees no session on navigation requests.

How to tell: The frontend and API hostnames have different registrable domains, and DevTools lists the session cookie against the API host or flags it as blocked.

Cause 2

Secure session cookie behind a reverse proxy

Browsers ignore the https requirement of Secure when the cookie is set by localhost, so a Secure cookie works locally. Behind a reverse proxy, Express may see the request as plain HTTP. In that case express-session with cookie.secure set does not set the cookie unless the proxy's X-Forwarded-Proto header is trusted.

How to tell: The login handler runs and returns successfully, but the production response carries no Set-Cookie header for the session.

Cause 3

SameSite=Strict on the session cookie

A Strict cookie is sent only with requests that originate from the site that set it. The return from an OAuth provider is a navigation that starts on the provider's site, so the first request after sign-in can arrive without the session cookie. In express-session, cookie.sameSite: true sets Strict, so check whether the production config differs from development.

How to tell: The first request after the OAuth redirect has no Cookie header, while the same URL loaded again from your own site does.

Cause 4

A Domain attribute that doesn't match the production host

The Domain value must be the sending server's domain or a parent of it, and it cannot be a public suffix such as co.uk. Browsers ignore cookies that break these rules. A Domain=localhost left in config, or an API setting a cookie for an unrelated frontend domain, is dropped in production.

How to tell: The Set-Cookie header is present, but its Domain value is neither the responding host nor a parent of it.

Check and fix it, step by step

  1. Check the login response in DevTools

    In the Network panel, select the login or OAuth callback request and open its Cookies tab. The Blocked response cookies filter shows requests whose response cookies were blocked. Hover over the info icon to see the reason.

    Docs: developer.chrome.com →

  2. Compare the frontend and API sites

    A site is the registrable domain: a Public Suffix List entry plus the label before it. SameSite rules also consider the scheme. Ports are ignored, so a split that is same-site on localhost can be cross-site in production.

    Docs: developer.mozilla.org →

  3. Trust the proxy before setting Secure cookies

    In Express with express-session, set trust proxy so the session middleware reads X-Forwarded-Proto from your reverse proxy, and keep cookie.secure on.

    app.set('trust proxy', 1) // trust first proxy
    app.use(session({
      secret: process.env.SESSION_SECRET,
      cookie: { secure: true }
    }))

    Docs: github.com →

  4. Serve the frontend and API from one site

    Subdomains of one registrable domain are the same site, so SameSite=Lax does not block the cookie between app.example.com and api.example.com. Calls between them are still cross-origin because the hostnames differ, so the credentials and CORS headers in the next step still apply. The cookie must also reach the API host: set it from the API, or set Domain=example.com.

    Docs: developer.mozilla.org →

  5. Send and allow credentials on cross-origin API calls

    Cross-origin fetch() sends no credentials by default, and browsers ignore Set-Cookie on a CORS response unless the request includes credentials. The server must answer with an explicit origin and Access-Control-Allow-Credentials: true. If the API is on a different site, the cookie also needs SameSite=None with Secure.

    fetch('https://api.example.net/login', { method: 'POST', credentials: 'include', body })
    // response headers:
    // Access-Control-Allow-Origin: https://app.example.com
    // Access-Control-Allow-Credentials: true
    // Set-Cookie: sid=<value>; Path=/; Secure; SameSite=None

    Docs: developer.mozilla.org →

  6. Use Lax instead of Strict for GET callbacks

    Lax cookies are sent on cross-site top-level navigations that use a safe method, which excludes POST. That covers an OAuth callback that returns as a GET redirect, where a Strict cookie is not sent. A callback delivered as a POST is not covered by Lax.

    Set-Cookie: sid=<value>; Path=/; Secure; SameSite=Lax

    Docs: developer.mozilla.org →

  7. Fix or drop the Domain attribute

    Leave out Domain to keep the cookie on the host that set it. Or set it to a parent domain that the app and API share, so a cookie from api.example.com also reaches middleware on app.example.com.

    Set-Cookie: sid=<value>; Domain=example.com; Path=/; Secure; SameSite=Lax

    Docs: developer.mozilla.org →

Quick check: grep -rnE "trust proxy|sameSite|secure:|domain:|credentials:" --exclude-dir=node_modules .

How often this shows up in our data

3 of the 344 verified cases from the last 12 months in our data match this symptom (0.9%). How we collect and verify cases.

With Gemmein

In a browser, Gemmein's SDK signs people in with email codes and keeps the session in BrowserTokenStore (localStorage, keyed per app key) by default. If that store cannot keep the session, verifyEmailCode throws secure_store_unavailable rather than reporting a session the app will lose, and calls from a page whose address is not listed on the Domains page are refused origin_not_allowed with a reason your code can branch on.

Questions

Why does a Secure session cookie work on localhost but not in production?

Browsers ignore the https requirement of Secure when the cookie is set by localhost. On other hosts, insecure sites (http:) cannot set cookies with the Secure attribute.


Will SameSite=None; Secure fix a frontend and API on different domains?

It lets the cookie be sent with cross-site requests, which also need credentials: "include", an explicit Access-Control-Allow-Origin and Access-Control-Allow-Credentials: true. Cookies set in CORS responses are still subject to third-party cookie policies, and a browser may be configured to reject them. Putting both on one site is the more reliable fix.


Why can't my frontend code read the Set-Cookie header?

The Fetch spec makes Set-Cookie a forbidden response header name, so browsers filter it out of responses exposed to frontend code. Check it in the DevTools Network panel instead.


Can my API set a cookie for my frontend's domain?

Yes, if they share a parent domain. A response from api.example.com can set Domain=example.com, which covers app.example.com. A cookie for an unrelated domain or a public suffix such as co.uk is ignored.


Sources

Every cause and step above was checked against these pages on 1 Oct 2026.

The broader pattern

This is one symptom of a wider failure pattern: Sign-in works locally but fails in production: how to fix it. The guide covers every cause we see for it, on any stack.

Get a heads-up when your stack breaks something

Leave your email and we'll let you know when something big changes for your stack. Unsubscribe any time by replying. Gemmein Limited. Research terms · Privacy

← All fixes