If you have written even a little frontend code, there is a good chance you have already met the dreaded CORS error. You wire up a fetch to an API, hit refresh, open the browser console, and there it is in angry red text: something about “blocked by CORS policy”. The request looked fine. The API works when you open it in a new tab. So why is the browser refusing to hand you the response?
The short answer is that CORS is not a bug, and it is not the browser being difficult. It is a security mechanism doing exactly its job. To really understand it, though, we need to step back and talk about a rule that sits underneath the whole web: the same-origin policy. Once that clicks, CORS stops feeling like a random obstacle and starts looking like a sensible door with a lock on it.
What “origin” actually means
Before anything else, let’s pin down the word origin, because everything here depends on it. An origin is the combination of three things in a URL:
- the scheme (like
httporhttps), - the host (like
blog.acy-partner.com), - and the port (like
443or3000).
If all three match between two URLs, they share the same origin. If even one differs, they are different origins. That last part trips up a lot of beginners, so here is a concrete table:
| URL A | URL B | Same origin? | Why |
|---|---|---|---|
https://acy-partner.com/page |
https://acy-partner.com/other |
Yes | Scheme, host, port all match |
https://acy-partner.com |
http://acy-partner.com |
No | Different scheme (https vs http) |
https://acy-partner.com |
https://blog.acy-partner.com |
No | Different host (subdomain counts) |
https://acy-partner.com |
https://acy-partner.com:8080 |
No | Different port |
Notice the path (/page vs /other) does not affect the origin. Only scheme, host, and port matter. And yes, a subdomain like blog. is treated as a completely separate origin from the bare domain.
The same-origin policy, in plain terms
The same-origin policy is a rule built into every browser. In one sentence: a web page can send requests to other origins, but by default it cannot read the responses from them.
That distinction between sending and reading is the heart of the whole topic, so let’s slow down on it. Your browser is happy to let one site trigger a request to another site. That is normal and necessary, because images, scripts, fonts, and stylesheets are constantly loaded across origins. What the browser guards carefully is whether the JavaScript on the page is allowed to look at the response data that comes back.
Why so strict? Imagine you are logged into your bank in one tab. In another tab, you visit some sketchy site. Without the same-origin policy, the JavaScript on that sketchy site could quietly make a request to your bank’s API. Because you are logged in, your browser would attach your session cookies automatically, and the bank would respond with your account details. If the sketchy page could then read that response, it could steal everything. The same-origin policy is the wall that stops the sketchy page from reading what came back, even though the request technically went through.
Send versus read
The browser usually lets a cross-origin request go out. What it blocks is your page reading the response. That is why your API call can hit the server and even run server code, yet your JavaScript still gets nothing but an error.
So where does CORS come in?
The same-origin policy is a sensible default, but it is too strict for the modern web. All the time, legitimate apps need to talk across origins. A frontend hosted at https://acy-partner.com might need to call an API at https://api.acy-partner.com. A single-page app on one domain might pull data from a public service on another. Blocking all of that would make a lot of useful architecture impossible.
CORS — Cross-Origin Resource Sharing — is the official way to relax the same-origin policy in a controlled manner. Here is the key idea, and it is worth memorizing:
CORS is the server opting in. The server tells the browser, “I am fine with this other origin reading my responses.” The browser only loosens its rule when the server explicitly says so.
This is an important mental shift for beginners. CORS is not something you configure on the frontend to “allow” your requests. The permission lives on the server that owns the data. The browser is the enforcer; the server is the one granting permission.
The header that does the work
The way a server grants this permission is through an HTTP response header called Access-Control-Allow-Origin. When the browser receives a cross-origin response, it looks for this header to decide whether the page is allowed to read the body.
A simple successful exchange looks like this:
GET /data HTTP/1.1
Host: api.acy-partner.com
Origin: https://acy-partner.com
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://acy-partner.com
Content-Type: application/json
{ "message": "Hello from the API" }
Two things happened. The browser, knowing this was a cross-origin call, automatically attached an Origin header naming where the request came from. The server responded with Access-Control-Allow-Origin echoing that same origin, which tells the browser, “yes, this origin may read me.” The browser then hands the JSON to your JavaScript.
If that header is missing, or if it names a different origin, the browser refuses to expose the response — and that is the CORS error you see in the console. The response often actually arrived; the browser just shredded it before your code could touch it.
Access-Control-Allow-Origin value |
Effect |
|---|---|
https://acy-partner.com |
Only that exact origin may read the response |
* (wildcard) |
Any origin may read it — used for truly public data |
| (header absent) | No cross-origin reading allowed; browser blocks it |
The wildcard has limits
Access-Control-Allow-Origin: * lets any origin read the response, but it cannot be combined with credentialed requests (ones that send cookies). If your request needs to send cookies, the server must name a specific origin and also send Access-Control-Allow-Credentials: true. A wildcard plus credentials is rejected by the browser on purpose.
Preflight: the polite knock before the door opens
So far we covered simple requests. But some requests are considered risky enough that the browser does not even send them right away. Instead, it sends a small “may I?” request first. This is called a preflight, and it uses the HTTP OPTIONS method.
A request triggers a preflight when it is not a “simple” request — for example when it uses methods like PUT or DELETE, or when it sends a header like Content-Type: application/json or a custom Authorization header. The reasoning is protective: such requests can change data on the server, so the browser checks permission before doing anything real.
Here is the full dance for a request that needs a preflight:
Browser (origin-A) Server (origin-B)
| |
| 1. OPTIONS /orders (preflight) |
| Origin: origin-A |
| Access-Control-Request-Method: PUT |
| --------------------------------------> |
| |
| 2. 204 No Content |
| Access-Control-Allow-Origin: A |
| Access-Control-Allow-Methods: PUT |
| <-------------------------------------- |
| |
| 3. Actual PUT /orders |
| --------------------------------------> |
| |
| 4. 200 OK + Access-Control-Allow-Origin|
| <-------------------------------------- |
In step 1 the browser asks, without sending the real payload, “I want to make a PUT request from origin-A with these headers — is that allowed?” In step 2 the server answers with which origins, methods, and headers it permits. Only if the answer covers what the real request needs does the browser proceed to step 3 and send the actual PUT. If the preflight is rejected, the real request never even leaves the browser.
The headers involved in a preflight response are worth a quick reference:
| Response header | Meaning |
|---|---|
Access-Control-Allow-Origin |
Which origin is allowed |
Access-Control-Allow-Methods |
Which HTTP methods are allowed (e.g. GET, POST, PUT) |
Access-Control-Allow-Headers |
Which request headers are allowed (e.g. Content-Type, Authorization) |
Access-Control-Max-Age |
How long the browser may cache this preflight result, in seconds |
That last one, Access-Control-Max-Age, is a nice optimization: it lets the browser remember the preflight answer for a while so it does not have to ask before every single request.
Why beginners keep hitting the CORS error
Now that the pieces are on the table, the classic beginner experience makes sense. You build a frontend on http://localhost:3000 and an API on http://localhost:5000. Different ports mean different origins, so the browser enforces CORS. Your API never set Access-Control-Allow-Origin, so the browser blocks the read, and you get the error — even though the server itself responded perfectly.
A few things that confuse people at this stage:
- The error is the browser’s doing, not the server’s. Tools like
curlor Postman do not enforce the same-origin policy, which is why your API “works” there but fails in the browser. Those tools are not browsers, so they have no page to protect. - You cannot fix CORS purely from the frontend. The permission has to come from the server’s response headers. No amount of tweaking your fetch options will make the browser trust an origin the server never approved.
- A failed preflight looks like a failed request. If your
OPTIONScall is rejected, the real request never fires, and the console may point at the main call, hiding the fact that the preflight was the real culprit.
The fix lives on the server
When you hit a CORS error, ask: “What origin is my page on, and does the server’s response include an Access-Control-Allow-Origin that matches it?” The solution is almost always to configure the server (or its CORS middleware) to allow your frontend’s origin — not to change the browser or the frontend.
A quick recap
Let’s tie it all together. The same-origin policy is a browser security rule: a page can send requests to other origins but, by default, cannot read the responses. An origin is the trio of scheme, host, and port — change any one and you have a new origin. This policy exists to stop a malicious page from quietly reading your private data on sites you are logged into.
CORS is the controlled way to relax that rule. It works by the server opting in: it sends an Access-Control-Allow-Origin header (and friends) telling the browser which origins are allowed to read its responses. For riskier requests, the browser first sends a preflight OPTIONS request to confirm permission before sending the real one. The infamous CORS error simply means the browser did not find permission it could trust, so it refused to expose the response to your code.
Once you internalize that CORS is permission granted by the server and enforced by the browser, the whole thing turns from a frustrating wall into a clear, sensible contract. From here, a natural next step is to look more closely at how cookies and the Access-Control-Allow-Credentials header behave together, since credentialed cross-origin requests have their own careful rules — and that is where a lot of the trickier real-world CORS puzzles live.