Notes

This Set-Cookie header didn't specify a "SameSite" attribute and was defaulted to "SameSite=Lax," and was blocked because it came from a cross-site response which was not the response to a top-level navigation.

Cookie Doctor (SameSite, Secure, Domain)

Your frontend signs in against an API on another domain. The login call returns 200 and even carries a Set-Cookie header, yet the next request leaves without the session cookie and the API answers as if nobody had logged in. Open the login request in Chrome's Network panel, switch to its Cookies tab and hover the info icon next to the cookie. This is what it says:

This Set-Cookie header didn't specify a "SameSite" attribute and was defaulted to "SameSite=Lax," and was blocked because it came from a cross-site response which was not the response to a top-level navigation.

The cookie reached the browser, and the browser refused to store it. Two facts met in one response. Your Set-Cookie header had no SameSite attribute, so Chrome treated it as SameSite=Lax, a default Chrome rolled out during 2020, targeting all users of Chrome 80 and newer from 11 August 2020. And the response answered a request that went to a different site than the one in the address bar: a fetch() call, an XMLHttpRequest, or a page loading inside an iframe. RFC 6265bis, the IETF draft that updates the cookie specification, puts the rule in one sentence: cookies with SameSite=Lax or SameSite=Strict "cannot be set in responses to cross-site subresource requests, or cross-site nested navigations." Only a top-level navigation, the kind that changes the address bar, may set them.

This default is Chrome's, and Edge's. MDN's browser compatibility data lists it for Chrome 80 and Edge 86; Firefox has it only behind a preference, and Safari does not have it. That is why the same login can behave differently in Firefox or Safari, where the third-party cookie rules in route B decide instead.

The full tooltip goes on with one more sentence. In the current DevTools source it reads: 'The "Set-Cookie" header had to have been set with "SameSite=None" to enable cross-site usage.' The quotes and the comma move between Chrome versions (the 2020 source prints "SameSite=Lax," with the comma inside the quotes, the current one "SameSite=Lax", and quotes "Set-Cookie" too), so search for the words rather than the punctuation. Its neighbors in the same DevTools tooltip list are worth recognizing:

  • "...blocked because it had the "SameSite=Lax" attribute but came from a cross-site response which was not the response to a top-level navigation" (or SameSite=Strict): you wrote the attribute yourself. Same cause, same fix.
  • "...blocked because it had the "SameSite=None" attribute but did not have the "Secure" attribute": you are halfway through the fix. See the traps below.
  • "...blocked because its "Domain" attribute was invalid with regards to the current host URL": a different problem, also covered in the traps.

Everything below hangs on one question: is your API really on another site? A different site is not the same thing as a different origin, and most of the confusion around this message comes from mixing the two.

What counts as the same site

An origin is scheme, host and port. A site is coarser: the scheme plus the registrable domain, which is one entry of the Public Suffix List plus the label just before it. Subdomains and ports do not count. So https://app.example.com and https://api.example.com are two origins but one site, and so are http://localhost:3000 and http://localhost:8000. The scheme does count: web.dev calls this schemeful same-site, and MDN notes that the SameSite rules use this stricter definition, so http://example.com and https://example.com are two sites. A host that is an IP address has no registrable domain, so the HTML Standard makes the address itself the site.

The surprise is hosting platforms. vercel.app, netlify.app, onrender.com, herokuapp.com, up.railway.app, fly.dev, pages.dev and github.io are all entries of the Public Suffix List (we checked the list on 27 September 2026). To a browser, my-app.vercel.app and my-api.vercel.app are as unrelated as two companies.

You do not have to work it out by hand. In the Network panel, look at the request headers of your login call. For a request your page makes itself (not from inside an iframe), Sec-Fetch-Site: cross-site means the browser sees two sites, and a cookie without SameSite from that response will be dropped. same-site or same-origin means SameSite is not what blocks you; go straight to the traps. Two limits: browsers send this header only to URLs they consider potentially trustworthy, which in practice means https:// and localhost, and after a redirect the value reflects every URL in the chain. If the header is missing, compare scheme and registrable domain by hand, as in the diagram.

Which page and API pairs are the same site A site is the scheme plus the registrable domain; ports and subdomains are ignored. Six pairs are compared part by part. https://app.example.com and https://api.example.com: same site, because subdomains of one registrable domain are one site. http://localhost:3000 and http://localhost:8000: same site, the port is ignored, but they are still cross-origin. http://localhost:3000 and http://127.0.0.1:8000: cross-site, different hosts, and an IP address is its own site. https://my-app.vercel.app and https://my-api.vercel.app: cross-site, because vercel.app is a public suffix. https://app.example.com and https://my-api.onrender.com: cross-site, different registrable domains. http://app.example.com and https://api.example.com: cross-site, same domain but a different scheme. Bottom box, what Chrome and Edge do with a cookie set by a cross-site fetch (Firefox and Safari differ): with no SameSite attribute it is treated as Lax and dropped; SameSite=None without Secure is rejected; SameSite=None with Secure is stored only if the fetch used credentials: 'include' and the browser allows third-party cookies. SITE = SCHEME + REGISTRABLE DOMAIN parts match parts differ PAGE https://app.example.com API https://api.example.com same site Subdomains of one registrable domain: one site. PAGE http://localhost:3000 API http://localhost:8000 same site The port is ignored. Still cross-origin. PAGE http://localhost:3000 API http://127.0.0.1:8000 cross-site Different hosts. An IP address is its own site. PAGE https://my-app.vercel.app API https://my-api.vercel.app cross-site vercel.app is a public suffix. PAGE https://app.example.com API https://my-api.onrender.com cross-site Different registrable domains. PAGE http://app.example.com API https://api.example.com cross-site Same domain, different scheme. A COOKIE SET BY A CROSS-SITE FETCH In Chrome and Edge. Firefox and Safari differ. no SameSite (so Lax) dropped SameSite=None rejected SameSite=None; Secure stored Stored only with credentials: 'include', and only if third-party cookies are allowed.
Each row compares the two parts of a site, character by character: the scheme and the registrable domain. Ports and subdomains are left out because the definition of a site leaves them out. The bottom box shows what Chrome and Edge do with a cookie set by a fetch() that crosses sites. Firefox applies the Lax default only behind a preference and has rejected SameSite=None without Secure since version 131; Safari does neither, but blocks third-party cookies anyway. The rules come from MDN's glossary entry for a site, web.dev's article on same-site and same-origin, the HTML Standard's "obtain a site" algorithm, the Public Suffix List, RFC 6265bis and MDN's browser compatibility data.

01

Frontend and API on two sites in production

The most common setup behind this message: the frontend on your own domain or on Vercel or Netlify, the API on its platform's default domain, such as onrender.com or herokuapp.com. Every call from the page to the API is cross-site, so the login response's cookie, which has no SameSite attribute, is dropped. Code like this may have worked years ago: Chrome rolled the Lax default out in stages during 2020, and on 11 August 2020 raised the target to 100% of users on Chrome 80 and newer.

Fix: make the page and the API one site (route A in the steps below), or declare the cookie cross-site (route B).

02

Local development against a remote API, or localhost against 127.0.0.1

The 2021 question in the sources describes it: frontend and server share a domain in production, but in development the frontend runs on localhost and talks to the deployed server, so logging in fails only on the developer's machine. Until Chrome 91, a switch in chrome://flags hid the problem; Chrome 91 removed it, and the Chromium team announced the command-line switch's removal for Chrome 94. A quieter variant from the same thread: the frontend on http://localhost:8080, the backend on http://127.0.0.1:8000. One machine, two hosts, two sites.

Fix: run the API locally as well (one answer points out that localhost:1234 and localhost:5678 are the same site), use the same host name for both, or let your dev server proxy the API (route A, option 2). None of these needs a weaker production cookie.

03

Your app lives inside someone else's page

An embedded widget, an app shown in an iframe of a dashboard or a shop's admin, a login form inside an iframe: here even the response to the iframe's own page load is a cross-site nested navigation, and a Lax cookie cannot be set there. A subdomain or a proxy does not help, because the top-level site belongs to someone else. You need SameSite=None; Secure (route B), and third-party cookie blocking and partitioning still apply. MDN's guide to third-party cookies names two tools for this case: the Partitioned attribute (CHIPS), which keeps a separate cookie jar per top-level site, and the Storage Access API, which asks the user for permission per frame.

04

The cookie is not yours

If the blocked cookie belongs to an analytics script, a chat widget or a CDN, only its owner can change the header. The most viewed question on the topic, from 2019 when Chrome still printed a console warning instead of blocking, is exactly this: the cookies came from jQuery's, Font Awesome's and Google's domains, and the accepted answer says it has nothing to do with the asker's code. Filter the Network panel by your API's domain and look only at your own requests.

The fix, step by step

  1. Confirm the call is cross-site. Check Sec-Fetch-Site on the login request (sent only to https:// and localhost URLs), or compare scheme and registrable domain as in the diagram. If the answer is same-site, SameSite is not your problem; the traps below are.
  2. Pick a route. Route A makes the page and the API one site, so the default Lax cookie is stored and third-party cookie blocking does not apply to it. Route B keeps two sites and marks the cookie as cross-site. Take B only when A is not possible, for example inside someone else's iframe.
  3. Route A, option 1: a subdomain. Serve the API as api.example.com next to app.example.com. The two are still different origins, so the login call needs credentials: 'include', and the API has to answer with your exact origin in Access-Control-Allow-Origin plus Access-Control-Allow-Credentials: true. A JSON login also triggers a preflight, which needs the same two headers plus Access-Control-Allow-Headers: Content-Type (details in route B). The note on CORS with credentials covers those headers. One Safari caveat: if api.example.com is a CNAME to another domain, such as your hosting platform's, and the page's own host does not resolve through that same CNAME, Safari 14 and newer caps cookies set in the API's responses at 7 days (WebKit's CNAME cloaking defense). The cookie is stored, but a login meant to last longer ends after a week. The proxy in option 2 does not hit this, because the cookie comes from the page's own host.
  4. Route A, option 2: a proxy on the frontend's origin. The page calls a path on its own origin and the frontend server forwards the request to the API. The browser sees a single origin, so there is no CORS and the cookie is first-party. In Next.js, a rewrite to an external URL does this; the documentation describes rewrites as a URL proxy that masks the destination:

    next.config.js

    module.exports = {
      async rewrites() {
        return [
          {
            source: '/backend/:path*',
            destination: 'https://my-api.onrender.com/:path*',
          },
        ]
      },
    }
    In development with Vite, server.proxy does the same. Vite passes further options to http-proxy-3, whose cookieDomainRewrite: '' removes a Domain attribute that the API sets for its own host, which the browser would otherwise reject on localhost:

    vite.config.js

    export default defineConfig({
      server: {
        proxy: {
          '/api': {
            target: 'https://staging-api.example.com',
            changeOrigin: true,
            cookieDomainRewrite: '',
          },
        },
      },
    })
  5. Route B: mark the cookie as cross-site. Add SameSite=None and Secure, both of them: a SameSite=None cookie without Secure is rejected by Chrome and Edge, and by Firefox since version 131, according to MDN's browser compatibility data.

    The header the browser needs

    Set-Cookie: session=abc123; Path=/; HttpOnly; Secure; SameSite=None
    With express-session, the cookie options map one to one. Behind a proxy that terminates HTTPS, its README says to set trust proxy, or a secure cookie is not set at all:

    Express with express-session

    app.set('trust proxy', 1);
    app.use(session({
      secret: process.env.SESSION_SECRET,
      resave: false,
      saveUninitialized: false,
      cookie: { httpOnly: true, secure: true, sameSite: 'none' },
    }));
    In Django, both settings exist for the session cookie and default to 'Lax' and False. Django accepts 'None' as a value; browsers then also need Secure (see MDN), so set SESSION_COOKIE_SECURE = True as well. If the message names the csrftoken cookie instead, CSRF_COOKIE_SAMESITE and CSRF_COOKIE_SECURE work the same way:

    Django settings.py

    SESSION_COOKIE_SAMESITE = "None"
    SESSION_COOKIE_SECURE = True
  6. Route B: credentials and CORS. Send the login call itself with credentials, not only the calls after it. For a cross-origin request without credentials, MDN says the browser ignores Set-Cookie in the response, whatever the cookie's attributes say:

    Client

    await fetch('https://my-api.onrender.com/login', {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });
    // XMLHttpRequest and axios: withCredentials: true
    The API then has to answer with the exact origin, never *:

    API response headers

    Access-Control-Allow-Origin: https://app.example.com
    Access-Control-Allow-Credentials: true
    Vary: Origin
    Because the login sends Content-Type: application/json, the browser first sends a preflight OPTIONS request (a request can skip it only if, among other conditions, its content type is form-encoded, multipart or text/plain). The answer to that preflight needs the same two Access-Control-Allow-* headers plus the one below. With credentials, a * there is not a wildcard, so name the header. Without it the preflight fails, the login request is never sent, and there is no Set-Cookie to block. The note on headers not allowed by Access-Control-Allow-Headers covers that error.

    Also on the preflight (OPTIONS) response

    Access-Control-Allow-Headers: Content-Type
  7. Route B: know its limits. A SameSite=None cookie from an API on another site is a third-party cookie. Safari has blocked cookies for cross-site resources by default since Safari 13.1 and iOS 13.4 (March 2020). Firefox, with its default Enhanced Tracking Protection, keeps third-party cookies in a separate cookie jar per site. Chrome does not block them by default, only in Incognito mode or when the user turns blocking on. Such a cookie also travels with requests that other sites trigger, so protect state-changing endpoints against cross-site request forgery, for example with CSRF tokens or a Sec-Fetch-Site check.

How to tell it is fixed

  1. In the Network panel, open More filters and tick Blocked response cookies, then log in again. The filter keeps only requests whose response cookies were blocked, so if your login request no longer shows up, its cookie was stored.
  2. Select the login request and open its Cookies tab: the session cookie has no info icon next to it. Select the next request to the API: its Cookies tab lists the session cookie among the cookies it sent.
  3. Read the raw header the server sends, because a framework default or a proxy can change attributes on the way out:

    Terminal

    curl -i https://my-api.onrender.com/login \
      -H "Content-Type: application/json" \
      -d '{"email":"you@example.com","password":"..."}'
    The set-cookie line should carry the attributes of the route you chose. No set-cookie line at all, with express-session behind a proxy, points to the trust proxy trap below. On Windows, call curl.exe in PowerShell, because in Windows PowerShell 5.1 curl is an alias for Invoke-WebRequest, and put the command on one line without the backslashes. Windows PowerShell 5.1 also strips the double quotes inside '{...}' before curl.exe sees them (we checked: the argument arrives as {email:you@example.com,password:...}), so the API gets invalid JSON and the login fails without a set-cookie line, which looks like the trust proxy trap but is not. PowerShell 7.3 and newer pass the quotes through. The way around it that does not depend on the shell's quoting is to save the body as login.json and let curl read the file:

    Windows, body from a file

    curl.exe -i https://my-api.onrender.com/login -H "Content-Type: application/json" --data-binary "@login.json"
  4. Route B only: repeat the login in Safari and in a Chrome Incognito window. If the cookie is missing there but works in a normal Chrome window, that is third-party cookie blocking, and route A is the lasting fix.

Traps

  • SameSite=None without Secure. The cookie is rejected, and DevTools says it "did not have the "Secure" attribute, which is required in order to use "SameSite=None"". RFC 6265bis tells browsers to ignore such a cookie entirely.
  • Secure behind a proxy that terminates HTTPS. express-session's README: "If secure is set, and you access your site over HTTP, the cookie will not be set." Behind a proxy, set app.set('trust proxy', 1). Without it there is no Set-Cookie header, and so no DevTools message to read either.
  • A Domain attribute the browser refuses. The 2022 question in the sources had Domain=https://domain.me and got "its Domain attribute was invalid with regards to the current host URL"; removing https:// fixed it. The value is a domain, not a URL, and MDN says it must be the responding server's own domain or a parent of it: an API on my-api.onrender.com cannot set Domain=example.com. The browser ignores cookies that break this rule.
  • A login call without credentials. With fetch()'s default credentials: 'same-origin', or XMLHttpRequest without withCredentials, the response to a cross-origin request cannot set cookies, even when the page and the API are the same site. MDN's page on withCredentials adds that this holds "regardless of Access-Control- header values".
  • Looking for the cookie in document.cookie. An HttpOnly cookie never shows up there, yet the browser still sends it with fetch(). Check the Cookies tab in DevTools instead.
  • Old switches and hand edits. The chrome://flags entries from older answers are gone since Chrome 91. Editing the cookie to SameSite=None in DevTools, which two answers in the 2021 question suggest, changes one cookie in one browser; your users still get the message.

Check your case

Cookie Doctor takes the raw Set-Cookie header, the page origin, the API origin, the credentials mode and the context, and names the rule that stops the cookie. Open Cookie Doctor with this note's example filled in: session=abc123; Path=/; HttpOnly from https://my-api.onrender.com, for a fetch() with credentials: 'include' on https://app.example.com. It reports the default Lax as the reason the cookie is blocked on a cross-site fetch, and gives the corrected line session=abc123; Path=/; Secure; HttpOnly; SameSite=None. It cannot see your server, so it also lists the CORS pair from route B as something the API must send. It decides what is the same site with a short built-in list of public suffixes, not the full Public Suffix List, so check the list yourself for an unusual domain. It runs in your browser and does not call your API. Replace the example with your own header and origins.

Questions

Are localhost:3000 and localhost:8000 cross-site?

No. The port is not part of a site, so they are the same site, and SameSite does not stop a Lax cookie set by the API on port 8000. They are still two origins, though: the fetch needs credentials: 'include' for the cookie to be stored and sent, and the API needs CORS headers for requests with credentials. localhost and 127.0.0.1, on the other hand, are two different hosts and therefore two sites.

Do I need HTTPS in development to use SameSite=None?

Usually not. If the page and the API both run on localhost, they are the same site and you do not need SameSite=None at all. If you do need it, MDN notes that the https requirement of the Secure attribute is ignored when the cookie is set by localhost. The 2021 question gave up on SameSite=None because of certificates; running the API locally or proxying it avoids the question.

Why is the cookie stored in Chrome but not in Safari?

With SameSite=None; Secure on an API that is another site, the cookie is a third-party cookie. Safari has blocked cookies for cross-site resources by default since Safari 13.1 and iOS 13.4 (March 2020). Chrome does not block third-party cookies by default, only in Incognito mode or when the user turns it on. The lasting fix is to put the page and the API on one site.

Can I turn Chrome's old cookie behavior back on?

No. The chrome://flags switches for the Lax default and for SameSite=None requiring Secure were removed in Chrome 91, and the command-line switch was announced for removal in Chrome 94. Downgrading Chrome, which one old answer suggests, only hides the problem on one machine.

Is SameSite=None safe for a session cookie?

It gives up the protection Lax gave you: the cookie now travels with requests that other sites trigger, which is what cross-site request forgery relies on. MDN warns that credentials in cross-origin requests can make a site vulnerable to CSRF. Keep HttpOnly and Secure, and protect state-changing endpoints with CSRF tokens or a Sec-Fetch-Site check, two of the defenses in MDN's CSRF guide. Putting the page and the API on one site avoids the trade-off.

Sources