A junior developer on my team spent most of a day on this error:
Access to fetch at 'https://api.example.com/orders' from origin
'https://app.example.com' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.
He tried a browser extension that disables CORS. He tried adding headers to the request. He tried a proxy. All three are the wrong move, and all three are the top results when you paste that message into a search box.
The reason CORS is so consistently frustrating is that almost every explanation gets one thing backwards, and once that clicks the error stops being mysterious.
The Request Was Not Blocked
Here is the part nobody says clearly enough: your request was sent. The server received it. The server did the work. The browser then refused to let your JavaScript read the response.
CORS is not a firewall. It does not stop traffic. It is a rule the browser applies to what your code is allowed to see.
Which explains three things that otherwise make no sense:
The same URL works fine in Postman or curl. Those are not browsers, so no CORS.
You see a 200 in the Network tab but an error in the console. The response arrived; reading it was denied.
A GET request that deleted something still deleted it. The block came after the damage.
It also explains why you cannot fix CORS from the client. The browser is enforcing a rule on the server's behalf, and only the server can grant permission. Every "disable CORS" extension works by turning off that enforcement in your browser only — your users still get the error, and you have now made your own machine less safe while debugging.
Why the Rule Exists
Without it, any site you visited could make requests to your bank, with your cookies attached, and read the response. The same-origin policy prevents that by default. CORS is the mechanism a server uses to say "actually, this particular other origin may read my responses."
An origin is scheme + host + port. All three. So https://app.example.com and https://api.example.com are different origins, and so are http://localhost:3000 and http://localhost:8080. That last pair is why the error shows up on day one of every project with a separate frontend and backend.
The Preflight Is Where People Get Stuck
Some requests the browser just sends. Others it checks first with an OPTIONS request, and that check is where most confusing failures live.
No preflight for a "simple" request: GET, HEAD, or POST with a content type of text/plain, multipart/form-data or application/x-www-form-urlencoded, and no unusual headers.
Preflight for everything else — which in practice means anything you actually build: application/json, any Authorization header, any custom header, and every PUT, PATCH or DELETE.
So the sequence is:
→ OPTIONS /orders
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
← 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 86400
→ POST /orders (the real request, only now)
Two failure modes come from this. First, your router only handles POST on that path, so the OPTIONS request 404s and the browser never sends the real one. Second, you added a custom header like X-Request-Id on the client and forgot to list it in Access-Control-Allow-Headers — the preflight fails and the error message talks about the header, which people read past.
Access-Control-Max-Age is worth setting. Without it the browser preflights repeatedly; with it, once per day per endpoint.
Credentials Change the Rules
The moment you send cookies, the wildcard stops working. If the request has credentials: "include", the server must echo the exact origin and set Access-Control-Allow-Credentials: true. A response of Access-Control-Allow-Origin: * is rejected outright by the browser.
This is deliberate — it stops a server accidentally granting every site on the internet authenticated access. In practice it means an allowlist:
const ALLOWED = new Set([
"https://app.example.com",
"https://staging.example.com",
"http://localhost:3000",
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && ALLOWED.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin); // exact, not *
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Vary", "Origin"); // or a CDN caches one origin's response for all
}
if (req.method === "OPTIONS") {
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE");
res.setHeader("Access-Control-Allow-Headers", "content-type,authorization");
res.setHeader("Access-Control-Max-Age", "86400");
return res.sendStatus(204); // answer before auth middleware runs
}
next();
});
Three details there are the ones that cause afternoon-long debugging sessions.
Vary: Origin — without it, a CDN or proxy caches the response including its Allow-Origin header and serves it to a different origin, which then breaks intermittently. Intermittent CORS failures are almost always this.
The OPTIONS request must not require authentication. Browsers do not send credentials on a preflight. If your auth middleware runs first and returns 401, the preflight fails and the real request never happens. Handle OPTIONS before auth.
Reflect the origin from an allowlist, never blindly. Echoing back whatever Origin arrives, combined with credentials, means any website can read your authenticated responses. That is a real vulnerability and it is exactly what "just make CORS work" ends up doing.
The Fixes, Ranked
Best: same origin. Serve the API under the same domain as the app — example.com/api — via a reverse proxy or your framework's rewrite config. No CORS at all, no preflights, and cookies work naturally. If you control both sides, do this.
Good: a proper allowlist, as above, when the API is genuinely on another domain.
Acceptable: a dev proxy. Next.js rewrites, Vite's proxy option. Fine for local development. Just remember it is not how production works, so test the real path before you ship.
Only for genuinely public data: *. A read-only public API with no cookies and no auth. Nothing else.
Never: a browser extension. It fixes your machine and nobody else's.
Reading the Error Properly
The message names the failure, and there are only a few:
"No 'Access-Control-Allow-Origin' header is present" — the server sent nothing. Often the endpoint 404'd or errored before CORS middleware ran.
"The value of the 'Access-Control-Allow-Origin' header ... must not be the wildcard" — you are sending credentials. Echo the exact origin.
"Method PATCH is not allowed" — missing from Allow-Methods.
"Request header field x-foo is not allowed" — missing from Allow-Headers.
"Redirect is not allowed for a preflight" — your OPTIONS hit an HTTP→HTTPS or trailing-slash redirect.
Once you know the request already reached the server and the browser is holding the response hostage, every one of those points at a header you control. My colleague's day would have been ten minutes if anyone had told him that first.



