Why Cookies Fail in CORS: From withCredentials to SameSite

CORS cookies depend on more than withCredentials. The server CORS headers, Cookie Domain, SameSite, Secure, and third-party cookie policy all matter.

Years ago, I debugged a common CORS cookie problem: a frontend page called an API across origins, the backend returned a value in the response body, and the frontend wrote it into document.cookie. Chrome DevTools showed that a cookie existed, but the backend never received it on the next request.

The simple answer was: cookies are scoped by domain. A cookie written by page JavaScript belongs to the page’s host. It does not become a cookie for the API host just because the page made a cross-origin request. The backend should use Set-Cookie instead of asking the frontend to write the cookie manually.

Chinese version of this article

That answer is still correct, but it is no longer enough. Modern browsers added SameSite defaults, require Secure for SameSite=None, restrict third-party cookies, and support newer mechanisms such as partitioned cookies. Debugging CORS cookies today requires more than checking withCredentials.

Separate Three Concepts First

Three concepts are often mixed together:

  • Same-origin: scheme, host, and port are all the same. https://www.example.com and https://api.example.com are different origins. So are http://localhost:3000 and http://localhost:63342.
  • Same-site: usually based on scheme plus the registrable domain. https://www.example.com and https://api.example.com are usually same-site. https://example.com and https://other.com are cross-site.
  • Cookie scope: decided by the host that set the cookie plus attributes such as Domain and Path. Ports are not part of cookie scope.

CORS controls whether a script from one origin can read a response from another origin. Cookies control which stored cookies are automatically attached to a request for a host/path. SameSite controls whether cookies are allowed on same-site or cross-site requests.

Because these are different checks, some cases look surprising:

  • localhost:63342 requesting localhost:3000: cross-origin, so CORS is needed; but both use the localhost host, so cookies can look shared while debugging.
  • www.example.com requesting api.example.com: cross-origin, so CORS is needed; but usually same-site, so SameSite=Lax may still allow cookies.
  • app.example.com requesting api.other.com: cross-origin and cross-site, so CORS, SameSite, and third-party cookie policy all matter.

A Working CORS Cookie Flow

The frontend must explicitly include credentials. Fetch only sends cookies by default for same-origin requests. Cross-origin requests need credentials: 'include':

await fetch('https://api.example.com/me', {
  method: 'GET',
  credentials: 'include'
});

For XHR or axios, the corresponding setting is:

xhr.withCredentials = true;
axios.get('https://api.example.com/me', {
  withCredentials: true
});

The server must also allow credentialed CORS requests:

  • Access-Control-Allow-Origin must be an explicit origin, not *.
  • Access-Control-Allow-Credentials must be true.
  • If the server reflects allowed origins dynamically, it should also return Vary: Origin to avoid cache confusion.
  • Preflight OPTIONS requests do not include cookies, but their responses still need to indicate whether the real request is allowed to include credentials.

An Express example:

const allowList = new Set([
  'https://www.example.com'
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;

  if (allowList.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  }

  if (req.method === 'OPTIONS') {
    return res.sendStatus(204);
  }

  next();
});

Finally, the cookie should be set by the API host through Set-Cookie. If the page is https://www.example.com and the API is https://api.example.com, they are same-site but cross-origin:

Set-Cookie: __Host-sid=...; Path=/; HttpOnly; Secure; SameSite=Lax

This is a host-only cookie. It is sent only to api.example.com. HttpOnly prevents JavaScript from reading it, which is appropriate for session cookies. Secure requires HTTPS. SameSite=Lax is often enough for same-site requests.

If the page and API are cross-site, for example https://app.example.com calling https://api.other.com, a cookie intended for cross-site requests needs at least:

Set-Cookie: sid=...; Path=/; HttpOnly; Secure; SameSite=None

That only means the cookie has attributes that allow cross-site sending. It does not guarantee the cookie will work, because browser or user-level third-party cookie policy may still block it.

Why document.cookie Does Not Fix It

document.cookie = 'sid=123' writes a cookie for the current page’s host. If the page is on www.example.com, the script cannot write a cookie for api.other.com.

Even with the Domain attribute, a page can only set cookies for the current host or a parent domain that contains it. For example, a response from api.example.com can set Domain=example.com, but it cannot set Domain=other.com.

That is why returning a cookie value in JSON and asking the frontend to write it into document.cookie is usually the wrong design:

  • The cookie belongs to the frontend page’s domain, not the API domain.
  • If the session cookie needs HttpOnly, JavaScript should not be able to write or read it.
  • Set-Cookie is a forbidden response header for frontend JavaScript. Access-Control-Expose-Headers: Set-Cookie does not make it readable.

The correct flow is: the API returns Set-Cookie; the browser stores it if CORS, credentials, cookie attributes, and browser policy all allow it; later requests attach the cookie automatically.

SameSite Changed Old Advice

Older CORS articles often said that cross-origin cookies work once withCredentials and Access-Control-Allow-Credentials are configured. Today, that advice is missing SameSite.

Modern browsers usually treat cookies without an explicit SameSite value as Lax. Lax sends cookies on same-site requests and on some top-level cross-site navigations, but it does not freely attach cookies to cross-site fetch, XHR, or iframe subresource requests.

So if a cross-site API request depends on cookies, the cookie generally needs:

Set-Cookie: sid=...; Path=/; HttpOnly; Secure; SameSite=None

Two details matter:

  • SameSite=None must be paired with Secure.
  • Secure means production should use HTTPS. localhost has development exceptions, but local behavior should not be treated as production behavior.

If the frontend and API can be placed under the same site, prefer that design:

  • https://www.example.com
  • https://api.example.com

This still requires CORS because the origins are different, but the SameSite pressure is much lower because the request is usually same-site.

CORS Cannot Bypass Third-Party Cookie Policy

Even if CORS headers, credentials: 'include', and SameSite=None; Secure are all correct, cookies can still be blocked. Browser third-party cookie policy sits outside the CORS configuration.

MDN’s CORS documentation explicitly notes that credentialed cross-origin requests are still subject to third-party cookie policies. Frontend and server settings cannot override user-agent policy.

At minimum, browser behavior should be understood separately:

  • Safari/WebKit has restricted third-party cookies for a long time and moved to full third-party cookie blocking in 2020.
  • Firefox Enhanced Tracking Protection blocks some tracking-related third-party cookies.
  • Chrome announced in 2025 that it would keep giving users control over third-party cookies instead of launching a new standalone prompt. Incognito mode blocks third-party cookies by default, and users can also disable them in privacy settings.

So third-party cookies should not be treated as stable login infrastructure for normal web applications. The more robust design is to avoid putting the frontend site and login cookie site on completely different sites.

If the product is truly a third-party embedded component, such as an iframe widget, map, support chat, payment flow, or cross-site embedded app, then APIs such as Storage Access API and CHIPS/Partitioned Cookies may be worth evaluating. They have specific use cases and should not be the default for ordinary frontend/backend login.

Choosing a Design

In order of stability, I would choose:

  1. Same-origin deployment: serve the frontend and API under the same origin, or use Nginx/BFF to proxy /api to the backend. Cookies are simplest and CORS mostly disappears.
  2. Same-site but cross-origin: for example www.example.com plus api.example.com. CORS and credentials: 'include' are still needed, but cookies remain in the same-site model.
  3. Cross-site without cookie-based API identity: public APIs, cross-organization APIs, and mobile APIs are better served by OAuth, short-lived tokens, or Authorization headers.
  4. Cross-site and cookie-based: only choose this when browser compatibility, user settings, and embedded context are fully understood, and when there is a fallback for blocked third-party cookies.

A reverse proxy is not a primitive workaround. For applications you control, making the browser see the frontend and API as one site is often more reliable than fighting browser privacy policy.

Security Boundary

Cookies are attached automatically by the browser. That is also why CSRF exists. CORS is not CSRF protection. A cross-site form submission or simple request can still be sent; the attacker page may simply be unable to read the response.

If an API uses cookies as login state, at least consider:

  • Use HttpOnly; Secure for session cookies.
  • Prefer SameSite=Lax when possible instead of SameSite=None.
  • For state-changing requests, validate a CSRF token or check request-origin signals such as Origin and Sec-Fetch-Site.
  • Do not reflect every Origin into Access-Control-Allow-Origin.
  • Do not use Access-Control-Allow-Origin: * on credentialed CORS responses.

Cookies carry identity automatically. They do not prove that a request is trustworthy.

Debugging Checklist

When a CORS cookie is not being received, check in this order:

  1. Is the request cross-origin? Compare scheme, host, and port.
  2. Did the frontend set fetch(..., { credentials: 'include' }) or withCredentials = true?
  3. Does the response contain an explicit Access-Control-Allow-Origin, and is it not *?
  4. Does the response contain Access-Control-Allow-Credentials: true?
  5. If origin is dynamic, is Vary: Origin present?
  6. Is the cookie set by the API host through Set-Cookie, not returned in the response body?
  7. Do Domain and Path cover the next request URL?
  8. For cross-site requests, is the cookie SameSite=None; Secure?
  9. Is production using HTTPS?
  10. Is the browser blocking third-party cookies?
  11. Does DevTools show the cookie as blocked, and what is the blocked reason?
  12. Does the Application panel show the cookie under the expected site?

In Chrome DevTools, the Cookies subpanel inside a specific Network request is often more useful than looking only at raw headers. Cookies blocked by SameSite, Secure, Domain, or third-party cookie policy often show a reason there or in the Issues panel.

Summary

CORS cookies work only when the whole chain lines up:

  • The frontend allows credentials.
  • The server allows that specific origin to send credentials.
  • The cookie is set by the target domain through Set-Cookie.
  • Domain, Path, SameSite, and Secure match the request scenario.
  • Browser third-party cookie policy does not block it.

The root cause in that old 2017 bug was: the frontend cannot write cookies for the backend domain. The modern addition is: even correctly set backend cookies must be designed with SameSite, Secure, and third-party cookie restrictions in mind.

Further Reading