Every developer meets this message, and most of the advice about it is wrong in the same way - it treats CORS as an error to be silenced rather than a rule about who is allowed to read a response.

Access to fetch at 'https://api.example.com/users' from origin
'https://myapp.dev' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.

The server was fine. The browser blocked you.

This is the part that reframes everything else. CORS is enforced by the browser, not the API. In most cases the request was sent, the server did respond, and the browser then refused to hand the response to your JavaScript.

# The server answers you perfectly well from a terminal.
curl -i https://api.example.com/users
HTTP/2 200
content-type: application/json

# Same request from a page on another origin: blocked.
# Not because the server refused - because the browser did.

Two consequences follow immediately. First, a CORS error is not evidence that the API is down or that your URL is wrong - test with curl before you debug the wrong layer. Second, and more importantly: CORS is not a server-side security control. It stops a page on one origin reading a response from another. It does nothing to stop anyone calling your API from a terminal, a script, or a server. If your API needs protection, it needs authentication, not CORS.

What CORS does protect is real, though. Without it, any page you visited could issue requests carrying your cookies for your bank, your email, your internal tooling, and read the answers. The same-origin policy is what keeps one tab from reading another origin's data using your ambient credentials.

Simple requests and preflights

Some cross-origin requests go straight out. Others send an OPTIONS request first, asking permission. A request avoids the preflight only if it meets all of these:

ConditionAllowed values
MethodGET, HEAD, POST
Headers you setAccept, Accept-Language, Content-Language, Content-Type, Range
Content-Typeapplication/x-www-form-urlencoded, multipart/form-data, text/plain

Note what is missing. application/json is not a safelisted content type, and Authorization is not a safelisted header. So a typical authenticated JSON API call - POST, a bearer token, a JSON body - always preflights. That is why the failure often appears on an OPTIONS request you never wrote.

OPTIONS /users HTTP/1.1
Origin: https://myapp.dev
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.dev
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 86400

Access-Control-Max-Age is the one people forget. Without it the browser preflights every single request, doubling your round trips.

Fixing it, in order of correctness

1. Set the headers, if the API is yours

The only fix that addresses the actual cause:

// Express. The correct fix when you control the API.
app.use((req, res, next) => {
  res.header("Access-Control-Allow-Origin", "https://myapp.dev");
  res.header("Access-Control-Allow-Headers", "Authorization, Content-Type");
  res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
  res.header("Access-Control-Max-Age", "86400");
  if (req.method === "OPTIONS") return res.sendStatus(204);
  next();
});

The wildcard trap

Access-Control-Allow-Origin: * is fine for genuinely public data and breaks the moment credentials are involved. The browser rejects a wildcard on any credentialed response, even though the server said yes:

// Works for public data
Access-Control-Allow-Origin: *

// Silently fails the moment cookies or credentials are involved:
// the browser blocks the response even though the server allowed it.
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

// Required instead - echo the specific origin
Access-Control-Allow-Origin: https://myapp.dev
Access-Control-Allow-Credentials: true

2. Proxy in development

When the API is someone else's, a dev server proxy removes the cross-origin situation entirely - the browser only ever talks to your own origin:

// vite.config.js - development only
export default {
  server: {
    proxy: {
      "/api": {
        target: "https://api.example.com",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ""),
      },
    },
  },
};

3. Proxy in production

Same idea, permanently: your backend calls the API and passes the result on. Server-to-server calls have no CORS, because there is no browser applying it.

4. What not to do

  • Do not launch Chrome with --disable-web-security. It fixes your machine and nobody else's, and it turns off the protection for every site you then visit.
  • Do not reach for a public CORS proxy in production. You are routing your users' requests, and any credentials in them, through a stranger's server.
  • Do not reflect the Origin header unconditionally. Echoing whatever origin asked, alongside Access-Control-Allow-Credentials: true, is the same as having no policy - while looking like you have one.

What a proxy actually costs

A proxy works because it moves the request outside the browser. That is also the whole of its risk: every header in that request, including Authorization, passes through whoever runs it. Trusting a proxy is exactly as serious as trusting a service with your API keys, and it should be decided on those terms.

A proxy also becomes an SSRF vector if it fetches whatever it is told to. The classic target is the cloud metadata endpoint, which is reachable from the server and from nowhere else:

# A naive proxy will happily fetch anything you name, including
# addresses only it can reach. On a cloud host that is a live
# credential endpoint:
{"url": "http://169.254.169.254/latest/meta-data/iam/"}
{"url": "http://localhost:8080/admin"}

# A proxy that is built correctly refuses them:
{"error": "Blocked: localhost targets are not allowed."}

Any proxy worth using blocks private and link-local addresses, enforces a timeout, and caps the response size. If you are evaluating one, that refusal is the behaviour to test for first.

Run a curl command and map the response

The Execute cURL tab on JSON Keyper fetches a live API response through a proxy and extracts its key paths, so you can see the shape of a payload without building a client first.

Open JSON Keyper

What that means for your data

The same tradeoff described above applies to this site's own cURL tab, so it is worth stating plainly: the parsed request is sent to a Cloudflare Worker we operate, which fetches the API and returns the response. It keeps no logs and no storage, refuses private and localhost addresses, times out at 5 seconds and caps responses at 2 MB - but a credential in your curl command does travel through it.

Use it for public and test endpoints freely. For anything carrying production credentials, prefer your own proxy. The privacy policy covers exactly what is and is not retained. Pasting JSON directly is unaffected and never leaves your browser.

A debugging order that saves time

  1. curl the endpoint. If that fails too, it is not CORS.
  2. Look for an OPTIONS request in the network tab. If it is there and failing, fix the preflight response, not the real one.
  3. Read the message. A missing Access-Control-Allow-Origin, a disallowed header, and a wildcard-with-credentials are three different bugs with three different fixes.
  4. Check for credentials. Cookies or credentials: "include" mean the wildcard can never work.
  5. Only then reach for a proxy, and prefer one you run.

Further reading