“Blocked by CORS policy” is one of the most searched browser errors, and CORS is one of the most misunderstood pieces of web security. Many developers treat it as a firewall for their API: list the allowed domains, and everyone else is locked out. It isn’t that. CORS is a way for a server to loosen a browser rule, and the browser is the only thing that enforces it.
Interviewers test this with questions like “why does the request work in curl but fail in the browser?” and “does CORS stop the request from reaching the server?” This note answers both with a local API, two pages on different ports, and a real Chromium browser.
The rule underneath: same-origin policy
An origin is the scheme, host and port together. http://localhost:5173 and http://localhost:8095 are different origins because the ports differ. So are http:// and https:// versions of the same host, and app.example.com versus api.example.com.
Browsers have always let pages send plenty of cross-origin requests: a form can post anywhere, an <img> can load from any host. What the same-origin policy restricts is reading. A script on one origin can’t read a response from another unless that response says it may. The headers that say so are CORS (Cross-Origin Resource Sharing).
The reason is the user’s credentials. Your browser holds cookies for your bank, your email and your company’s internal tools. Without the same-origin policy, any page you visit could call those services with your cookies attached and read the answers.
Watching a browser enforce it
The test API runs on port 8095 with three endpoints. Only /api/allowed sends a CORS header, and only for one origin:
const ALLOWED = new Set(['http://localhost:5173']);
http.createServer(async (req, res) => {
const origin = req.headers.origin;
console.log(`api: ${req.method} ${req.url} from Origin: ${origin ?? '(none)'}`);
let body = '';
for await (const chunk of req) body += chunk;
if (req.url === '/api/allowed') {
if (ALLOWED.has(origin)) { res.setHeader('access-control-allow-origin', origin); res.setHeader('vary', 'Origin'); }
return res.end(JSON.stringify({ secret: 'only for allowed origins' }));
}
if (req.url === '/api/no-cors') return res.end(JSON.stringify({ data: 'no CORS headers here' }));
if (req.url === '/api/notes' && req.method === 'POST') {
notes.push(body);
console.log(`api: saved note #${notes.length}: ${body}`);
return res.end(JSON.stringify({ saved: notes.length }));
}
});The same test page was served on ports 5173 and 5174. It calls each endpoint with fetch and prints what happened, including a POST with a text/plain body. Loaded from http://localhost:5173 in Chromium, the page showed:
page origin: http://localhost:5173
GET /api/allowed: status 200, body {"secret":"only for allowed origins"}
GET /api/no-cors: TypeError: Failed to fetch
POST /api/notes (text/plain): TypeError: Failed to fetchAnd the console explained:
Access to fetch at 'http://localhost:8095/api/no-cors' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.Loaded from port 5174, all three failed, including /api/allowed, because the API only names 5173. Notice what the page’s code sees: a generic TypeError: Failed to fetch, with no status code and no details. Browsers hide the reason from scripts on purpose, so a page can’t use CORS errors to probe other servers. Only the developer console shows the cause.
The requests still reached the server
Here’s the API’s log for both page loads:
api: GET /api/allowed from Origin: http://localhost:5173
api: GET /api/no-cors from Origin: http://localhost:5173
api: POST /api/notes from Origin: http://localhost:5173
api: saved note #1: hello from http://localhost:5173
api: GET /api/allowed from Origin: http://localhost:5174
api: GET /api/no-cors from Origin: http://localhost:5174
api: POST /api/notes from Origin: http://localhost:5174
api: saved note #2: hello from http://localhost:5174Every request arrived, and both POSTs saved a note, even though the browser told the page they failed. The browser sent the request, received the response, and then refused to hand it to the script.
That happens for “simple” requests: GET, HEAD or POST with only basic headers and a content type of text/plain, application/x-www-form-urlencoded or multipart/form-data. These are things an HTML form could already do before CORS existed, so browsers send them without asking first. Requests with other methods, custom headers or a JSON content type trigger a preflight OPTIONS check before the real request is sent.
The practical consequence: CORS doesn’t protect state-changing endpoints from cross-site requests. If a simple POST from any website can change data, you need CSRF defences (SameSite cookies, CSRF tokens, or requiring a non-simple header or content type), whatever your CORS settings say.
curl ignores all of it
The same allowed-only endpoint, called with curl and a hostile Origin header:
curl -si http://localhost:8095/api/allowed -H 'Origin: https://evil.test'HTTP/1.1 200 OK
{"secret":"only for allowed origins"}No CORS header in the response, and the “secret” came back anyway. Access-Control-Allow-Origin is an instruction to browsers about what a page may read. curl, Postman, a Python script or another server doesn’t read it, and the Origin header is just a string a non-browser client can set to anything.
So “the API works in curl but not in the browser” is expected behaviour, not a bug in either. And “we locked CORS down to our own domain, so the API is protected” is wrong: an API needs real authentication and authorization on every request.
What CORS does protect, and how to break it
CORS protects users from pages that would read data using the users’ own ambient credentials. That’s why the rules get stricter when credentials are involved. To let a page read a response to a request sent with cookies (credentials: 'include'), the server must return Access-Control-Allow-Credentials: true and name the exact origin. The wildcard * isn’t allowed with credentials.
That makes * less dangerous than it sounds. On a public, unauthenticated API (exchange rates, public docs) it’s perfectly fine: there are no user credentials for a hostile page to borrow.
The dangerous configurations are the “clever” ones:
- Reflecting any
Originback with credentials allowed. Every website the user visits can now read authenticated responses. That’s the hole the same-origin policy exists to close. - Loose matching. Checking
origin.endsWith('example.com')also allowsevil-example.com. Compare against an exact list. - Allowing
null. Sandboxed iframes and some redirects sendOrigin: null, and an attacker can produce it on purpose. - Forgetting
Vary: Originwhen the header depends on the request’s origin. A cache can store the response allowed for one origin and serve it to another.
Interview practice
A security review flags that your API returns Access-Control-Allow-Origin: *. Separately, a new dashboard at dashboard.example.com needs to call the API with the user’s session cookie. A teammate proposes reflecting whatever Origin arrives and adding Access-Control-Allow-Credentials: true, which fixes both. How do you evaluate that?
Answer
The proposal makes things much worse. Today, * without credentials means any page can read only what an anonymous caller could already get, because browsers won’t attach cookies and allow reading under *. Reflecting any origin with credentials lets every website the user visits make authenticated calls and read the results, which removes the protection the same-origin policy gives our users.
I’d first check what the * endpoints return. If they’re truly public, the finding may be acceptable as is. For the dashboard, I’d keep an explicit allowlist (https://dashboard.example.com and any other first-party origins, compared exactly), return Access-Control-Allow-Credentials: true and the matched origin only for those, and add Vary: Origin. I’d also restate what CORS doesn’t do: the API still authenticates and authorizes every request itself, state-changing endpoints still need CSRF protection because simple cross-site requests reach the server regardless, and non-browser clients ignore all of this. If possible, I’d serve the API under the dashboard’s own origin through a proxy and avoid cross-origin credentials altogether.