You log in, the request returns 200, everything looks fine. Then you open the developer tools, go to the Application tab, and the cookie is simply not there. The next request goes out without it, the server treats you as a stranger, and you are back on the login page.

The frustrating part is that nothing failed. There is no error in the console, no warning in the terminal, no rejected promise. The browser received the cookie, looked at it, decided it was not allowed to keep it, and threw it away without telling anyone.

That silence is intentional. A cookie is storage that a website asks the browser to hold on its behalf, and the browser decides whether the request is acceptable. When it says no, it just says nothing.

There are only a handful of reasons this happens. Once you know them, this stops being a mystery and becomes a checklist.

Before changing any code, you need to answer one question: did the server never send the cookie, or did the browser refuse to store it? These are completely different problems, and people waste hours fixing the wrong one.

Open the Network tab, click the request that should be setting the cookie, and look at the response headers. You are looking for a line like this:

Set-Cookie: sessionId=abc123xyz; HttpOnly; Path=/; SameSite=Lax

If that header is not there, the problem is in your backend. Your server never asked for anything to be stored, so the browser has nothing to reject. Go and check your session middleware, your response code, or whether that branch of your login handler is even running.

If the header is there but the Application tab is still empty, the browser received the instruction and rejected it. Everything below is about that second case.

Browsers usually help you here. In Chrome, the Network tab shows a small warning icon next to a blocked cookie, and hovering over it tells you which rule was broken. Look there before you start guessing.

This is the most common cause today, and it became common because the default changed.

SameSite controls whether a cookie is attached to requests that come from a different site. If you do not set it at all, browsers now treat the cookie as SameSite=Lax. That means the cookie is sent on normal top level navigation, like clicking a link, but it is not sent on cross site requests made in the background by JavaScript.

So if your frontend runs on app.example.com and your API runs on api.otherdomain.com, a Lax cookie will not travel between them. Your login request succeeds, the cookie comes back, and it is either dropped or never sent again afterwards.

The fix is to say explicitly that the cookie is allowed to cross sites:

Set-Cookie: sessionId=abc123xyz; HttpOnly; Secure; SameSite=None

There is one rule that catches everybody: SameSite=None is only accepted together with Secure. If you send SameSite=None without Secure, the browser rejects the whole cookie. Not the attribute, the entire cookie. And Secure means HTTPS, which is why this often works in production and fails on your machine.

The restriction is worth understanding rather than working around. A cookie that travels on every cross site request is what made CSRF attacks easy for years, so the browser is refusing to hand your session to a site that did not earn it. It is the same instinct behind the fact that a server can never trust your browser.

Are you actually sending credentials with the request?#

A cookie can be stored correctly and still never leave the browser again.

By default, fetch does not send cookies to a different origin, and it does not store cookies that come back from one either. You have to ask for it:

await fetch("https://api.example.com/login", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email, password }),
});

In axios the equivalent is withCredentials: true. If you use a generated API client, check whether it exposes this option, because many do not send credentials unless you configure it.

The server has to agree as well. For a cross origin request with credentials, it must respond with Access-Control-Allow-Credentials: true, and Access-Control-Allow-Origin must name your exact origin. The wildcard * is not allowed once credentials are involved.

Both sides need to opt in, and this is the part that most often looks like a cookie problem when it is really a configuration problem.

Is Secure set while you are on an http page?#

The Secure attribute tells the browser to only store and send the cookie over HTTPS. On an http:// page, a cookie with Secure is dropped immediately.

This produces a confusing symptom: everything works in production and nothing works locally. Production is on HTTPS, your development server is on plain HTTP, and the same code behaves differently in each place.

Browsers do make an exception for localhost, which counts as a secure context even over HTTP. That exception is narrower than people expect though, and it does not cover a local network address like 192.168.1.40 or a custom hostname from your hosts file. If you test on your phone over the local network, this is very often the reason.

Does the Domain attribute match the site you are on?#

A cookie can only be set for the domain the response came from, or for a parent of it. A server on api.example.com can set a cookie for example.com, because that is its parent. It cannot set one for otherdomain.com, and if it tries, the browser discards the cookie.

The rules also work differently depending on whether you include the attribute at all:

Set-Cookie: sessionId=abc; Domain=example.com

That cookie is available on example.com and on every subdomain, including app.example.com and api.example.com.

Set-Cookie: sessionId=abc

That one, with no Domain, is only available on the exact host that set it. This is the mistake behind "the cookie exists but my subdomain cannot see it". Nothing is broken, the cookie was simply never shared in the first place.

Does the Path match the page you are looking at?#

Path limits the cookie to one section of the site. A cookie set with Path=/admin is not sent when you request /dashboard, and it will not appear in the Application tab while you are looking at a page outside that path.

Some session libraries set a narrow path by default, or inherit it from the route that created the session. If your cookie appears on one page and vanishes on another, check this before anything else. In almost every case you want Path=/.

Why localhost makes all of this worse#

localhost and 127.0.0.1 are two different hosts as far as cookies are concerned, even though they reach the same machine. A cookie set on one is invisible to the other. If your frontend calls http://localhost:3000 while your browser is open on http://127.0.0.1:3000, you will see exactly the symptoms described in this article.

Ports, on the other hand, are ignored. Cookies do not isolate by port, so localhost:3000 and localhost:5173 share the same cookie jar. That surprises people in the opposite direction, because a stale cookie from another project can quietly interfere with the one you are debugging.

The practical rule is to pick one hostname and use it everywhere, in your browser, in your API base URL, and in your environment files.

A checklist to work through#

When a cookie is missing, go through these in order:

  1. Is the Set-Cookie header present in the response? If not, the problem is in your backend.
  2. Does the browser show a warning icon next to that header? Read it first.
  3. Is the request cross site? If yes, you need SameSite=None; Secure and HTTPS.
  4. Is credentials: "include" set on the request, and does the server allow credentials with an explicit origin?
  5. Is Secure set while you are browsing over plain HTTP?
  6. Do the Domain and Path attributes cover the page you are testing on?
  7. Are you consistently on either localhost or 127.0.0.1, and not mixing the two?

Most missing cookie bugs are one of these seven, and they are usually found in under a minute once you start at the top instead of guessing.

Frequently asked questions#

Postman does not enforce SameSite, and it ignores the browser rules about cross origin requests and credentials. It stores whatever you tell it to. A cookie working in Postman only proves your server sends the header correctly, and tells you nothing about whether a browser would accept it.

If the cookie was set with HttpOnly, document.cookie cannot read it, by design. The cookie is still there and it is still sent with every matching request, but scripts on the page are locked out of it. This is deliberate protection for login sessions, because it means a script injected into your page cannot steal the session identifier.

That is a session cookie, which is any cookie set without Expires or Max-Age. The browser is doing exactly what it was asked. If you want the cookie to survive a restart, add a Max-Age in seconds.

Not necessarily. The server may have created the session record and stored it perfectly well, and only the identifier failed to reach the browser. The record and the cookie are two separate things, which is easier to see once you understand how sessions and tokens differ.

Why did this start failing without any code change?#

Browsers tightened cookie defaults over several releases, and the change to treat unspecified cookies as SameSite=Lax broke a large number of working integrations. If a cookie stopped being stored after a browser update, and the cookie crosses sites, that default is the first thing to check.