Dealing with CORS and Cookies
Introduction
CORS and cookies are crucial topics in front-end development. However, because the front end and back end often share the same domain during development, we rarely pay much attention to these issues. Or we just ask the backend to enable Access-Control-Allow-Origin: * across the board, seldom taking the time to understand the underlying mechanics.
MDN actually has a very comprehensive guide on this topic, so this article focuses on summarizing the key points and common pitfalls encountered in practice.
Same-Origin Policy
To prevent JavaScript from running wild across the web, the same-origin policy dictates that certain resources and code can only be accessed under the same origin.
So, what constitutes the same origin? The origin of a document is defined by its protocol, host, and port. This means that if Document 1 comes from http://kalan.com and Document 2 comes from https://kalan.com, they are not considered same-origin. What about subdomains, like https://api.foobar.com and https://app.foobar.com? Because their hosts differ, they are not from the same origin either.
However, some resources can inherently be fetched across origins:
<img /><video />,<audio /><iframe />: You can define headers to prevent others from embedding it- CSS stylesheets loaded via
<link rel="stylesheet" href /> - JavaScript loaded via
<script src="" />
In contrast, cross-origin requests initiated via code (such as Fetch or XHR) are restricted by the same-origin policy.
Obviously, such a policy is too strict. If everything were strictly confined to the same-origin policy, front-end and back-end development would be extremely difficult, and using XHR to integrate third-party SDK APIs would be impossible. Thus, the CORS (Cross-Origin Resource Sharing) mechanism was introduced.
CORS (Cross-Origin Resource Sharing)
Many people assume CORS is knowledge only front-end engineers need. However, CORS typically requires back-end configuration of the relevant headers, along with an understanding of their implications, to work properly.
So how do cross-origin requests work? Access control is primarily handled by two headers: Origin and Access-Control-Allow-Origin.
As long as the request’s Origin matches the value of Access-Control-Allow-Origin in the response header, or if Access-Control-Allow-Origin: * (meaning any domain is allowed to access the resource), the request succeeds.
If it violates CORS, the following error will be displayed:
If you attempt to read the returned object, you will also get a warning.
So… what happens if we follow the hint and change the fetch mode to no-cors?
Indeed, we got rid of the annoying error message, but the situation doesn’t seem any better.
no-cors is not a silver bullet. Even with this mode, CORS won’t magically open the doors—meaning your request still won’t succeed in returning usable data. That’s why you get a SyntaxError: Unexpected end of input error. This mode is typically used in conjunction with service workers.
From this experiment, we learn that there is only one way to unblock CORS: add the proper Access-Control-Allow-Origin on the server side (the host must match the origin, or be *).
In addition, CORS only applies when JavaScript issues an XHR or fetch request. Tools like cURL or Postman do not enforce this mechanism, which is why people often overlook it during API endpoint testing, leading to discrepancies between front-end and back-end testing results.
Some cross-origin requests do not trigger a preflight, while others do. MDN spells out the conditions quite clearly:
- Must be one of the methods: GET, HEAD, POST
- Apart from headers automatically set by the user agent and specific permitted headers, no custom headers are included. See acceptable headers.
- If
Content-Typeis set (note: this is the request header, not the response header), it must be one of the following:application/x-www-form-encoded,text/plain,multipart/form-data.
In other words, if any of the above conditions are not met, a preflight request will be issued.
Let’s try changing the Content-Type to application/json to trigger the preflight condition (since it’s not application/x-www-form-encoded, text/plain, or multipart/form-data).
Preflight
A preflight request means that an HTTP OPTIONS request is first sent to knock on the other domain’s door to verify everything is okay before sending the actual request. Once this condition is triggered, things get a bit more tedious:
- You must handle the
OPTIONSmethod on the same API endpoint, and setAccess-Control-Allow-Originto meet CORS requirements. - You must add
Access-Control-Allow-Headers, which must include all headers that fall outside the simple request conditions; otherwise, it will fail.
If it fails the preflight check, you will get an error like this:
Access to fetch at 'http://localhost:3001/trigger-preflight' from origin 'http://localhost:3000' has been blocked by CORS policy:
Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response.
Or if you didn’t include Access-Control-Allow-Origin in the OPTIONS response headers:
Access to fetch at 'http://localhost:3001/trigger-preflight' from origin 'http://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
If it succeeds, you will see two requests in the Network tab: one for OPTIONS, and the other for the actual request.
What if we add a custom header? According to MDN’s criteria, this should also trigger a preflight request. Let’s add an X-Access-Token and see what happens:
fetch("http://localhost:3001/trigger-preflight", {
headers: { "X-Access-Token": "dontbeserious" },
})
.then(res => res.json())
.then(log)
Indeed, it fails the preflight. To make it pass, X-Access-Token must be added to Access-Control-Allow-Headers.
Requests with Credentials
Cookies cannot be passed across origins—that is, cookies from different origins cannot be directly shared or accessed; otherwise, chaos would ensue. However, if you make a request from domain A to domain B, and domain B returns cookies, a cookie associated with domain B will be stored under domain A’s session. But if you don’t configure withCredentials or credentials: 'include', even if the server returns a Set-Cookie header, it will not be stored. As shown below:
Under normal circumstances, subsequent API calls to domain B will not send the cookie automatically. In this scenario, you must enable withCredentials on XHR or set { credentials: 'include' } in fetch. Since this is also a cross-origin request, it must comply with CORS requirements by specifying Access-Control-Allow-Origin:
fetch(`${hostname}/cookie`, {
method: "POST",
credentials: "include",
})
Access to fetch at 'http://localhost:3001/cookie' from origin 'http://localhost:3000' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.
To prevent security issues, browsers strictly require that Access-Control-Allow-Origin cannot be * when credentials are included.
Access to fetch at 'http://localhost:3001/cookie' from origin 'http://localhost:3000' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'.
But that alone isn’t enough; the browser will automatically reject any response lacking Access-Control-Allow-Credentials. Therefore, to pass credential information to a cross-origin server, you must also add Access-Control-Allow-Credentials: true. Once everything is properly configured, you should see the cookie successfully sent under Request Cookies, as shown below:
Alright, even after configuring all of this successfully, you might still fail to send cookies to the server. If so, it might be due to the following scenarios:
1. The user has blocked cookies for this domain
The user may have blocklisted your domain, preventing cookies from being sent.
Solutions:
- Change the domain
Reflect on why you were blocked by the user
2. The user blocks third-party cookies
This is sometimes enabled by default in Safari and has caused plenty of headaches during debugging.

Afterword
Dealing with CORS is often a thankless task. Forgetting to add Access-Control-Allow-Origin or Access-Control-Allow-Credentials can easily eat up another day waiting for CI/CD and deployment. I’ve gathered these common issues here in hopes that when similar problems arise in the future, the solution will be clear.
Fortunately, nowadays we have tools like AWS API Gateway, which can inject the required headers without modifying the main codebase, or you can solve the problem once and for all by setting up a reverse proxy under the same domain.
References
Related Posts
- Things to Keep in Mind When Using Images in Frontend Development Expanding on Jake Archibald's article, this post organizes how modern responsive images should be written: why width/height are still necessary, when to use CSS aspect-ratio, how to choose between AVIF and WebP, and using picture/source/srcset for art direction on mobile devices.
- CSS field-sizing — Auto-resize Form Elements with a Single Line of CSS Previously, auto-resizing a textarea required listening to scrollHeight in JavaScript. With CSS field-sizing: content, a single line replaces it all, supporting textarea, input, and select. This article covers the pain points of older approaches and how to use field-sizing.
- Make Your Link Underlines Look Better: text-underline-offset By default, underlines sit very close to the text. Some designers dislike this look, and personally, I don't think it looks great either.
- Why the Web Shouldn't Strive for Pixel Perfection You should only focus on pixel perfection when it truly matters; otherwise, it often results in a lose-lose situation.