Notes

No 'Access-Control-Allow-Origin' header is present on the requested resource:which response is missing it, and the fix

CORS Doctor

You call an API from your frontend, the request fails, and the browser console prints this:

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

Firefox words the same failure as Reason: CORS header 'Access-Control-Allow-Origin' missing. Either way, a request did leave the browser (for a preflighted call, only the OPTIONS preflight). For a plain GET or a form-style POST, your server most likely received it, ran it and answered. The browser then read the response headers, found no Access-Control-Allow-Origin, and refused to hand the response to your JavaScript. Your code only sees a failed request (fetch() rejects with a TypeError), and the reason is printed only in the console. That is also why the same call works in curl or Postman: they are not browsers, so nothing runs this check.

So the fix is always on the server, or in whatever sits in front of it. The useful question is not "how do I enable CORS" but "which response came back without the header". One fetch() can produce up to three kinds of response, and the browser checks every one of them. Below is how to tell them apart, then the causes roughly in the order they tend to show up.

First, find the response that is missing the header

Chrome ends several different situations with the same sentence. The words in front of No 'Access-Control-Allow-Origin' header is present narrow it down, and the status code of the failing response settles it:

  • "Response to preflight request doesn't pass access control check": the browser sent an OPTIONS request first, and the answer to that preflight had no header. In the reports linked below, the preflight came back as 405 or 501. MDN defines 405 as a method the target resource does not support (here OPTIONS) and 501 as functionality the server does not support. A preflight also needs a 2xx status, so adding the header to a 405 is not enough.
  • "(redirected from 'A')": the redirect itself passed the check, the browser followed it, and the response at the new URL (the one after "Access to fetch at") had no header. A typical case is an API that answers fetch() with a redirect to a login page (section 03).
  • Neither phrase: the response for the URL in the message had no header. Check that URL's status with the curl commands further down, or in the Network tab. A 301, 302, 303, 307 or 308 means the redirect itself lacks the header: current Chrome stops at that response and the message does not mention a redirect at all (section 03). A 401, 404 or 500 means that error is the real problem and the missing header is a side effect.
  • "Redirect from 'A' to 'B' has been blocked by CORS policy": the wording of older Chrome versions, reported for a redirect that itself had no header.
Which response is missing the header One fetch from http://localhost:3000 can produce three kinds of response. First, the OPTIONS preflight, sent for PUT or DELETE, JSON bodies or custom headers: it needs the header and a 2xx status, and the console then says Response to preflight request doesn't pass access control check. Second, a redirect, caused by a trailing slash, http to https, or a login page: every hop needs the header. If the 3xx itself lacks it, the console shows neither phrase; if the page it points to lacks it, the console says redirected from. Third, the actual response, including 401, 404 and 500: it needs the header on every status, and the console shows neither phrase. fetch() from http://localhost:3000 1 OPTIONS preflight response For PUT/DELETE, JSON bodies, custom headers Needs the header and a 2xx status CONSOLE SAYS Response to preflight request doesn't pass access control check: No ... 2 A redirect on the way (3xx) Trailing slash, http to https, a login page Every hop needs it: the 3xx and its target CONSOLE SAYS 3xx lacks it: neither phrase target lacks it: (redirected from 'A') 3 The actual response Also 401, 404 and 500 from error paths Needs the header on every status code CONSOLE SAYS NEITHER PHRASE has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header ...
One fetch() can get up to three kinds of response, in this order. The browser checks each one for Access-Control-Allow-Origin. The console wording narrows down which one failed; the status code settles it, because a bare 3xx and an error response both show neither phrase.

01

The API never sends the header

The plain case: the API has no CORS configuration, or it was never meant to be called from a page on another origin. If you own the server, add the header to the response with your frontend's exact origin:

Access-Control-Allow-Origin: http://localhost:3000

The value is a single origin, never a list. If several frontends call the API, read the request's Origin header, compare it with an allow-list, and send that same value back, together with Vary: Origin so caches keep the answers apart. Express runs middleware in the order you load it, and error handlers are defined last, so register this before authentication and your routes:

Express, registered before everything else

const allowed = new Set(['http://localhost:3000', 'https://app.example.com']);

app.use((req, res, next) => {
  const origin = req.headers.origin;
  res.setHeader('Vary', 'Origin');
  if (allowed.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
  }
  if (req.method === 'OPTIONS') {
    // the preflight: answer it here with a 2xx, before auth can reject it
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    res.statusCode = 204;
    return res.end();
  }
  next();
});

If you do not own the API, there is no fix in the browser. Call it from your own backend, or through a small proxy you control, and let the frontend talk to that instead.

02

The header is there on 200, but not on 401, 404 or 500

The browser checks the header on every response, whatever its status. The Fetch Standard says a response to a CORS request "can use any status" as long as it carries the headers. Setups that add them only on the happy path break in a confusing way: the error appears only on some calls, or only after a while. The usual ways the header goes missing on errors:

  • CORS is added after authentication, so a 401 for an expired token leaves without it. Your frontend then gets a network error (status 0 in XHR-based clients) instead of a 401, and never gets the chance to refresh the token.
  • An error handler or a framework's default 404 or 500 page builds its own response without the headers your middleware adds.
  • nginx: add_header only applies to 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308, unless you add the always parameter.

nginx

# without "always", 4xx and 5xx responses get no header
add_header Access-Control-Allow-Origin "https://app.example.com" always;

Fix the order, or add always, and then fix the actual error the missing header was hiding. curl shows you that status directly (see the checks further down).

03

A redirect sits between you and the real response

The Fetch Standard runs the CORS check on each response before it follows a redirect, so a 3xx without the header stops the request right there. Current Chrome then prints the plain message for the URL you called, with no mention of a redirect, so curl -i on that URL is the quickest way to see the 3xx and its Location header. These redirects come up again and again in the reports:

  • A missing trailing slash. The API answers /firstcolumn/2017-03-01/2017-10-26 with a redirect to the same path plus /, and that redirect carries no header. Call the final URL directly.
  • http to https, or the apex domain to www. Same fix: call the URL the server redirects to, so there is no hop.
  • A login redirect. Your backend answers a fetch() with a redirect to accounts.google.com or another identity provider. That login page will not send your origin a CORS header, and it is not meant to be fetched at all. If your backend put the header on the redirect, this is the case where the console says "(redirected from". Start the login as a navigation instead: a link, a form, or setting window.location.

If the request also needed a preflight, some browsers refuse to follow the redirect at all and say so in a separate message; MDN lists the workarounds for that case.

04

The host or platform decides the headers, not your code

When the response comes from static hosting, object storage or a platform in front of your code, that is where the setting lives:

  • Netlify static files: a _headers file next to index.html with a /* rule that adds the header.
  • Firebase Storage: downloading straight from the browser needs a CORS configuration on the Cloud Storage bucket, set from a cors.json file with gsutil cors set cors.json gs://<your-cloud-storage-bucket>.
  • Supabase Edge Functions: you add the headers yourself. The official example answers OPTIONS with them and passes the same headers on the success response and on the 400 in its catch branch.
  • Azure App Service: the platform has its own CORS setting. Microsoft's documentation says not to combine it with CORS code in your app: when both are used, App Service CORS takes precedence and your own CORS code has no effect. Use one or the other.
  • nginx in front of an app: by default, an add_header inside a location block replaces all the ones inherited from the server block. Adding any unrelated header there silently drops your CORS header for that location. Since nginx 1.29.3, add_header_inherit merge; in that location keeps the inherited headers as well.

05

Your allow-list has a slightly different origin

If your server echoes the origin only when it is on an allow-list, as the snippet above does, an origin that is not on the list gets no header at all, and you see this exact message. An origin is scheme, host and port, and all three have to match:

  • http://localhost:3000 and http://127.0.0.1:3000 are different hosts.
  • http://localhost:3000 and http://localhost:5173 are different ports.
  • https://example.com and https://www.example.com are different hosts, and http against https is a different scheme.

If the console says from origin 'null', one common cause is a page opened straight from disk as a file:// URL; a sandboxed iframe or a redirect across origins produces it too. Serve the page from a local dev server instead of adding null to the allow-list, which MDN advises against.

After the fix: the header sent twice

If the header is now added in two layers, for example in the app and again in nginx, the message changes to The 'Access-Control-Allow-Origin' header contains multiple values 'http://127.0.0.1:9000, http://127.0.0.1:9000', but only one is allowed. Browsers accept exactly one origin here. Keep the header in one layer and remove it from the other.

Check the fix outside the browser first

curl sends whatever Origin you give it and prints the raw response headers with -i, so you can look at each response the browser would see:

Terminal

# 1. the actual request
curl -i -H "Origin: http://localhost:3000" https://api.example.com/items

# 2. the error path: a route that does not exist, or no token
curl -i -H "Origin: http://localhost:3000" https://api.example.com/does-not-exist

# 3. the preflight, for PUT/PATCH/DELETE, JSON bodies or an Authorization header
curl -i -X OPTIONS \
  -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type, authorization" \
  https://api.example.com/items
  1. Every response contains Access-Control-Allow-Origin: http://localhost:3000. Header names are case-insensitive, so curl may print it in lowercase.
  2. The preflight returns a 2xx status, not 404 or 405.
  3. No response is a 301, 302, 303, 307 or 308 with a Location header. If one is, call that location directly.
  4. The header appears exactly once in each response.

Then reload the page with the Network tab open, select the request, and read its response headers there. If curl shows the header and the browser does not, the browser is getting a different response: a cached copy, or one from another layer.

Things that look like fixes but are not

  • mode: 'no-cors'. The error goes away, but the response becomes opaque: its headers and body are not available to JavaScript. You trade an error for an empty response.
  • Setting Access-Control-Allow-Origin on the request. It is a response header, so sending it from the client does nothing useful. Worse, it is not a CORS-safelisted request header, so it turns a simple request into one that needs a preflight.
  • Access-Control-Allow-Origin: * with cookies. The wildcard does not work for requests sent with credentials, so you swap this error for another one. Send the exact origin plus Access-Control-Allow-Credentials: true.
  • A browser extension or flag that turns CORS off. It changes only your own browser. Your users still get the error.
  • A cached response without the header. If the header depends on the origin and responses lack Vary: Origin, a copy cached from a normal page load, which had no header, can be reused for your fetch(). Send Vary: Origin, or send the same static header on every response.

Check it in 30 seconds

Paste the console error, or the page origin, the request URL and the response headers you see, into CORS Doctor. It works out whether the request needed a preflight, compares the returned origin byte for byte with the page origin, and flags the cases on this page: no header at all, a header missing because the route returned a 4xx or 5xx, localhost against 127.0.0.1, a header sent twice, and a redirect during a preflighted request. The check runs in your browser; it does not call your API.

Questions

Why does the same request work in Postman or curl?

CORS is enforced by the browser. Postman and curl are not browsers, so they show you the response whatever its headers say. That also makes them the right tools for checking what the server really sends.

Can I fix a missing Access-Control-Allow-Origin header from the frontend?

No. The response has to carry the header, so the fix belongs to the server, to a proxy you control, or to the platform in front of the API. mode: 'no-cors' only gives you an opaque response whose body JavaScript cannot read.

Is Access-Control-Allow-Origin: * safe to use?

For a public API that does not use cookies or other credentials, the wildcard is allowed. MDN advises never to use it for private APIs, and it does not work for requests sent with credentials at all.

Did my server run the request even though the browser blocked it?

For a simple request, yes: the browser sends it and only withholds the response, so a POST may already have written to your database. If a preflight was needed and failed, the actual request is never sent.

Why does the CORS error appear only on some requests?

Because only some responses lack the header: a 401 after a token expires, a 404 or 500 from an error path, a redirect, or an origin that is not on your allow-list. Compare a failing request with a working one in the Network tab; the status code or the origin will differ.

Sources