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.comandhttps://api.example.comare different origins. So arehttp://localhost:3000andhttp://localhost:63342. - Same-site: usually based on scheme plus the registrable domain.
https://www.example.comandhttps://api.example.comare usually same-site.https://example.comandhttps://other.comare cross-site. - Cookie scope: decided by the host that set the cookie plus attributes such as
DomainandPath. 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:63342requestinglocalhost:3000: cross-origin, so CORS is needed; but both use thelocalhosthost, so cookies can look shared while debugging.www.example.comrequestingapi.example.com: cross-origin, so CORS is needed; but usually same-site, soSameSite=Laxmay still allow cookies.app.example.comrequestingapi.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-Originmust be an explicit origin, not*.Access-Control-Allow-Credentialsmust betrue.- If the server reflects allowed origins dynamically, it should also return
Vary: Originto avoid cache confusion. - Preflight
OPTIONSrequests 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-Cookieis a forbidden response header for frontend JavaScript.Access-Control-Expose-Headers: Set-Cookiedoes 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=Nonemust be paired withSecure.Securemeans production should use HTTPS.localhosthas 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.comhttps://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:
- Same-origin deployment: serve the frontend and API under the same origin, or use Nginx/BFF to proxy
/apito the backend. Cookies are simplest and CORS mostly disappears. - Same-site but cross-origin: for example
www.example.complusapi.example.com. CORS andcredentials: 'include'are still needed, but cookies remain in the same-site model. - Cross-site without cookie-based API identity: public APIs, cross-organization APIs, and mobile APIs are better served by OAuth, short-lived tokens, or
Authorizationheaders. - 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; Securefor session cookies. - Prefer
SameSite=Laxwhen possible instead ofSameSite=None. - For state-changing requests, validate a CSRF token or check request-origin signals such as
OriginandSec-Fetch-Site. - Do not reflect every
OriginintoAccess-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:
- Is the request cross-origin? Compare scheme, host, and port.
- Did the frontend set
fetch(..., { credentials: 'include' })orwithCredentials = true? - Does the response contain an explicit
Access-Control-Allow-Origin, and is it not*? - Does the response contain
Access-Control-Allow-Credentials: true? - If origin is dynamic, is
Vary: Originpresent? - Is the cookie set by the API host through
Set-Cookie, not returned in the response body? - Do
DomainandPathcover the next request URL? - For cross-site requests, is the cookie
SameSite=None; Secure? - Is production using HTTPS?
- Is the browser blocking third-party cookies?
- Does DevTools show the cookie as blocked, and what is the blocked reason?
- 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
- MDN: Cross-Origin Resource Sharing
- MDN: Set-Cookie
- MDN: Using HTTP cookies
- MDN: Using the Fetch API - Including credentials
- Chrome for Developers: View, add, edit, and delete cookies
- Privacy Sandbox: Next steps for Privacy Sandbox and tracking protections in Chrome
- WebKit: Full Third-Party Cookie Blocking and More
- MDN: Cookies Having Independent Partitioned State