Skip to content
ohsudo, Home
Networking

"Blocked by CORS policy": what the browser is really refusing, and how to fix it on the server

Fix "has been blocked by CORS policy: No Access-Control-Allow-Origin header": why it happens, the fix on the server or in Nginx, preflight requests, and cookies.

Intermediate4 min readApplies to All browsers · Nginx · Express · FastAPI · any HTTP API

by OhSudo ··

Not sure this is your cause?Find it step by step: Website down →

Part of a learning path:Web servers: nginx and HTTPS in production

Access to fetch at 'https://api.example.com/items' 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 first surprise: your request probably worked. The server answered, and the browser is the one refusing to hand that answer to your JavaScript, because the server didn’t say this other site may read it. That’s why it works in curl, Postman or a new tab, as those don’t apply CORS.

The second surprise: you can’t fix this in your front-end code. The permission has to come from the server that’s being called.

  1. Your pagehttps://app.example.com
  2. The APIanswers, without Access-Control-Allow-Origin
  3. The browserblocks the answer from your JavaScript(breaks here)
The request usually worked. The browser refuses to give the answer to your page, because the server didn't say this origin may read it.

TL;DR

Make the API answer with the header, for your site only:

Access-Control-Allow-Origin: https://app.example.com
Vary: Origin

Or avoid CORS altogether, by serving the API and the app from one address, with a proxy. To see what your server really sends:

Terminal window
curl -sI -H 'Origin: https://app.example.com' https://api.example.com/items

1. What’s an origin?

An origin is the scheme, name and port together. All of these are different origins, so each of these calls needs CORS:

https://app.example.com → https://api.example.com another name
https://example.com → http://example.com another scheme
http://localhost:3000 → http://localhost:8000 another port

The last one is why you meet this in development so often.

2. See what the server sends

Pretend to be your site, and read the headers:

Terminal window
curl -sI -H 'Origin: https://app.example.com' https://api.example.com/items

Paste that into the HTTP Headers Analyzer, which points out the usual CORS mistakes. Your browser’s Network tab shows the same thing. Is there a response at all? A 500 from your code often has no CORS headers, since an error stopped it before they were added. The browser then reports a CORS error, which hides the real one. Look at the status code first.

3. Fix it in the application

Using your framework’s support is usually best.

Express:

import cors from 'cors';
app.use(cors({ origin: 'https://app.example.com' }));

FastAPI:

from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_methods=["*"],
allow_headers=["*"],
)

4. Or fix it in Nginx

map $http_origin $cors_origin {
default "";
"https://app.example.com" $http_origin;
"https://admin.example.com" $http_origin;
}
server {
listen 8080;
location /api/ {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Vary Origin always;
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
add_header Access-Control-Max-Age 3600 always;
add_header Vary Origin always;
return 204;
}
proxy_pass http://127.0.0.1:3000;
}
}

The map allows a list of sites, and answers with just the one that’s asking, since the header can only hold one origin. always keeps it on error responses, where it’s easy to lose.

Do it in one place. If your application and Nginx both add it, the browser receives two, and refuses:

The 'Access-Control-Allow-Origin' header contains multiple values 'https://app.example.com, https://app.example.com', but only one is allowed.

5. The simplest fix: no CORS at all

If the app and its API sit behind the same address, then there’s nothing to allow. With a proxy, /api is on the same origin:

location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
}

The guide for React and Vite has the full setup. In development, Vite’s server.proxy setting does the same, so your code can call /api.

6. Preflight requests

Before a “non-simple” request, the browser asks permission, with an OPTIONS request. That covers PUT, DELETE, JSON bodies, and headers like Authorization:

Response to preflight request doesn't pass access control check: It does not have HTTP ok status.

So your server has to answer OPTIONS with a 2xx, and these headers. Test it:

Terminal window
curl -si -X OPTIONS https://api.example.com/items \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: PUT' \
-H 'Access-Control-Request-Headers: authorization'

A login check that rejects OPTIONS is a typical cause, since a preflight carries no credentials. So the 401 or 403 gets reported as a CORS failure.

7. Cookies and credentials

With credentials: 'include', the rules get stricter:

The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

So you need a specific origin, plus this:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

The cookie itself must be SameSite=None; Secure for it to travel between sites. So this only works over HTTPS.

Warning

Don’t reflect whatever Origin arrives while allowing credentials, as some snippets do. It lets any website make logged-in requests as your users, and read the answers. Allow specific sites. Browser extensions that “disable CORS” only hide the problem on your own machine, and your users won’t have them. * is fine for truly public data with no login.

Shell, we have a problem!

Similar, but different: Mixed Content means an HTTPS page calling plain HTTP. net::ERR_CERT_DATE_INVALID is an expired certificate. ERR_TOO_MANY_REDIRECTS is a redirect loop, and a redirect during preflight fails too. A 502 or 504 from Nginx often shows up as a CORS error as well, so check Nginx 502. The Nginx Config Generator writes the proxy setup.

Found this useful? Share it.

Found a mistake? Let us know at contact@ohsudo.com.

Tools

Try it yourself

Articles

Related articles