CORS Explained: Why Your API Call Is Blocked
It works in curl, it works in Postman, and the browser still says 'blocked by CORS policy.' CORS isn't a firewall on your API. It's the browser deciding whether your JavaScript may read a response. Once you see it that way, the errors make sense, and so do the misconfigurations that turn it into a real vulnerability.
It Works in Postman
Your frontend on https://app.example.com calls your API on https://api.example.com. The request works in curl and in Postman. In the browser, the console shows:
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.
The usual next move is to search the error, paste Access-Control-Allow-Origin: * into the server config, and move on. Sometimes that works. Sometimes it produces a new error. And sometimes it "works" by opening a hole that lets any website read your users' data.
The confusion starts with a wrong mental model. CORS doesn't block anything. The browser was already blocking; CORS is how your server tells the browser it's allowed to make an exception.
The Rule CORS Relaxes: Same-Origin Policy
Browsers attach cookies automatically. If you're logged into your bank and visit evil.example, any request that page's JavaScript makes to your bank goes out with your bank cookies. Without some restriction, that page could fetch bank.com/api/balance and read the answer.
The same-origin policy is that restriction: JavaScript can send many kinds of cross-origin requests, but it can only read the response from its own origin.
The combination of scheme + host + port. https://app.example.com and https://api.example.com are different origins. So are http://localhost:3000 and http://localhost:5173, and so are http:// and https:// versions of the same host. The path doesn't matter. Two URLs are same-origin only if all three parts match exactly.
| Page origin | Request to | Same origin? | Why |
|---|---|---|---|
| https://app.example.com | https://app.example.com/api/orders | Yes | Scheme, host, and port all match |
| https://app.example.com | https://api.example.com/orders | No | Different host (subdomains count) |
| http://localhost:3000 | http://localhost:8080/api | No | Different port |
| https://app.example.com | http://app.example.com/api | No | Different scheme |
| http://localhost:3000 | http://127.0.0.1:3000/api | No | Different host string, even though it's the same machine |
A protocol, built from HTTP headers, that lets a server tell the browser which other origins may read its responses, with which methods and headers, and whether cookies may be included. The server declares a policy and the browser enforces it. curl, Postman, and your backend services never check CORS at all, which is why the same request works there.
Simple Requests vs Preflighted Requests
Not every cross-origin request is handled the same way. The browser sorts them into two groups, and that split explains most of the confusion.
A simple request is one an HTML form could already have sent before CORS existed: GET, HEAD, or POST, with only safelisted headers, and a Content-Type (if any) of application/x-www-form-urlencoded, multipart/form-data, or text/plain. Since the web always allowed sites to send these, CORS doesn't add a check up front. The browser sends the request, receives the response, and then decides whether JavaScript may read it.
Anything else is preflighted. That includes PUT, PATCH, DELETE, a JSON body (Content-Type: application/json), and an Authorization header. Before sending the real request, the browser sends an OPTIONS request asking whether the server allows it. The real request goes out only if the preflight response says yes.
Preflight
OPTIONS /orders with Origin, Access-Control-Request-Method: PUT, and Access-Control-Request-Headers: authorization,content-type. It carries no cookies and no Authorization header, even if the real request will.
Server declares its policy
A 2xx response with Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers, and optionally Access-Control-Max-Age so the browser can cache the answer.
The real request, checked again
If the preflight allows it, the browser sends the actual PUT. The response must also include a matching Access-Control-Allow-Origin, or JavaScript still can't read it.
Try it. Each scenario is a real-world failure. Check which requests get preflighted, what the server actually receives, and which error the console shows.
fetch("https://api.example.com/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
})A Blocked Response Doesn't Mean a Blocked Request
Pick the "Blocked, but it ran" scenario above. It's a plain POST with text/plain, which is a simple request, so there's no preflight. The server receives it, runs the handler, writes to the database, and responds. The browser then hides the response from JavaScript. Your code sees TypeError: Failed to fetch, and the side effect has already happened.
That's why CORS isn't CSRF protection. A malicious page can't read your API's responses, but it can still send simple requests, with cookies attached, and trigger any side effects they cause. Defending against that is a separate job: SameSite cookies, CSRF tokens, and refusing state-changing requests that don't carry a non-simple header or a JSON content type (which forces a preflight the attacker's origin will fail).
🔴 CORS protects your users' browsers, not your API
CORS controls which websites can use a logged-in user's browser to read your responses. It does nothing against a script, a bot, or curl, which ignore it completely. A strict CORS policy isn't authentication, authorization, or rate limiting. If your API's security depends on the Origin header, it has no security, since any non-browser client can set Origin to whatever it likes.
Credentials: Where Most Mistakes Happen
By default, cross-origin fetch doesn't send cookies. To send them you set credentials: "include", and the rules get stricter:
- The server must return
Access-Control-Allow-Credentials: true. Access-Control-Allow-Origincan't be*. It has to be the exact requesting origin.- The same rules apply to the preflight response.
The wildcard ban is deliberate. * means "any site may read this," which is fine for public, unauthenticated data. Combined with cookies, it would mean "any site may read this user's private data," so browsers refuse.
And that leads to the most common CORS vulnerability. A developer hits the wildcard error, and the quick fix is to echo back whatever Origin the request sent:
# "Works" for every origin, which is the problem
response.headers["Access-Control-Allow-Origin"] = request.headers["Origin"]
response.headers["Access-Control-Allow-Credentials"] = "true"
# Now evil.example can run, in a logged-in victim's browser:
# fetch("https://api.example.com/me", { credentials: "include" })
# ...and read the response. The browser sees an exact origin match.That's worse than having no CORS at all. The same thing happens with loose matching: origin.endsWith("example.com") also matches evilexample.com, and an unanchored regex like /example\.com/ matches example.com.attacker.net. Allowlisting the literal origin null is just as bad, because sandboxed iframes and some redirects send Origin: null, and an attacker can produce those on purpose.
ALLOWED = {"https://app.example.com", "https://admin.example.com"}
origin = request.headers.get("Origin")
if origin in ALLOWED: # exact string match only
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Vary"] = "Origin" # the response depends on Origin
if request.method == "OPTIONS": # answer preflights *before* auth
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE"
response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
response.headers["Access-Control-Max-Age"] = "600"
return 204⚠️ Forget Vary: Origin and a cache will break CORS for you
If you set Access-Control-Allow-Origin dynamically, the response depends on the request's Origin. Without Vary: Origin, a CDN or shared cache can store the response generated for app.example.com and serve it to admin.example.com, whose browser then rejects it because the header names a different origin. The result is intermittent CORS failures that depend on which origin happened to fill the cache first.
"CORS Errors" That Aren't Really About CORS
Many CORS errors are other server problems that only show up as CORS errors, because the failing response doesn't carry CORS headers.
| Symptom | Actual cause | Fix |
|---|---|---|
| CORS error only when something goes wrong | Your 500 or 401 response comes from an error handler that runs outside the CORS middleware, so it has no CORS headers | Add CORS headers to error responses too; register CORS middleware first |
| Preflight fails with 'does not have HTTP ok status' | OPTIONS goes through auth middleware (no token in preflights, so 401) or has no route (404/405) | Handle OPTIONS before auth, return 204 |
| Preflight fails after a URL change | The API redirects (http→https, trailing slash) and the preflight can't follow the redirect | Call the final URL directly |
| Works on localhost:3000, fails on 127.0.0.1:3000 | Different origins | Allowlist the one you actually use |
| Allowlisted origin still rejected | A trailing slash or path in the configured origin (https://app.example.com/) | Origins never have a path; compare the exact string |
| Response works but a custom header reads as null | JS can only read CORS-safelisted response headers by default | Access-Control-Expose-Headers: X-Request-Id |
The first row is the most common. Always check the actual status code in the Network tab before touching your CORS config. A CORS error on top of a 500 is a 500.
Fixes That Don't Fix Anything
| Tempting fix | What actually happens |
|---|---|
| fetch(url, { mode: 'no-cors' }) | The error goes away because you get an opaque response: status 0, empty body, unreadable. Your code still can't see the data. |
| A browser extension that disables CORS | It works on your machine only, and it disables a protection for every site you visit. |
| A public CORS proxy | Every request, including your users' tokens, goes through someone else's server. |
| Access-Control-Allow-Origin: * everywhere | Fine for public, unauthenticated data. It fails as soon as you need cookies, which is when it would have been dangerous. |
✅ Often the best fix is to not be cross-origin
If you control both the frontend and the API, serve them from the same origin. Put the API behind the same domain at /api with a reverse proxy, a CDN path rule, or a framework rewrite (Next.js rewrites, Vite's server.proxy in development). No cross-origin requests means no CORS, no preflight round trip on every call, and no policy to get wrong. Keep CORS for APIs that genuinely serve other origins.
If you do need cross-origin calls, set Access-Control-Max-Age so the browser caches preflights. Otherwise every non-simple request costs two round trips. Browsers cap the value (Chromium at 2 hours, Firefox at 24), so there's no point setting it higher than that.
Configuring CORS
Do the frontend and API need to be on different origins?
Key Takeaways
- The same-origin policy blocks reading, and CORS is the server's opt-out. The browser enforces it; curl, Postman, and backend services ignore it.
- An origin is scheme + host + port. Different ports and subdomains are different origins.
- Simple requests reach your server even when they're "blocked." Only the response is hidden. CORS isn't CSRF protection.
- Non-simple requests get a preflight, and it never carries cookies or tokens. Answer OPTIONS before authentication runs.
- With credentials, no wildcard. Use an exact-match allowlist, set Allow-Credentials, and always send
Vary: Origin. Reflecting any origin is a vulnerability. - Check the status code first. Many CORS errors are 500s or 401s from handlers that skipped the CORS headers.
- If you control both sides, avoid cross-origin. Put the API behind the same origin and CORS stops mattering.
References
- MDN: Cross-Origin Resource Sharing (CORS), a thorough practical reference
- WHATWG Fetch Standard: CORS protocol, the normative definition of simple requests, preflights, and credentials
- PortSwigger Web Security Academy: CORS vulnerabilities, covering origin reflection, null origins, and parsing mistakes
- MDN: Access-Control-Max-Age, including browser caps
- Sessions vs JWT: The Auth Token Debate, which covers the cookie-vs-header choice that decides your credentials mode