Ch. 20 · Networking

CORS and Browser Response Access

CORS decides whether a page may read a cross-origin response, not whether it is sent. A browser test shows a blocked POST still saved.

~6 min readintermediateupdated Oct 11, 2026

“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 }));
  }
});
JavaScript

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 fetch
Text

And 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.
Text

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:5174
Text

Every 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'
Terminal
HTTP/1.1 200 OK

{"secret":"only for allowed origins"}
Text

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 Origin back 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 allows evil-example.com. Compare against an exact list.
  • Allowing null. Sandboxed iframes and some redirects send Origin: null, and an attacker can produce it on purpose.
  • Forgetting Vary: Origin when 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.

More in Networking

read ✓Networking · mid

HTTP Preflight Requests and Allowed Operations

What makes a browser send an OPTIONS preflight, how the server's answer gates the real request, and five preflights traced in a real browser.

~6 min readread →
read ✓Networking · mid

Networking: HTTP Redirects

Test 301, 302, 303, 307 and 308 against a local Node server with curl and fetch, and see which codes turn a POST into a GET and why.

~6 min readread →
read ✓Networking · mid

HTTP Connection Reuse and Latency

Measure what keep-alive saves, size a client connection pool with Little's law, and retry safely when a pooled socket was closed under you.

~6 min readread →
esc