web/cors-why-a-browser-blocks-a-request-your-server-happily-answered.md

CORS: why a browser blocks a request your server happily answered

The web · 5 min read ·

You call your own API from a web page and the browser says blocked by CORS policy, even though the address works in a new tab. The server is not broken: the browser is protecting the person using it.

This article starts from one rule, the same origin (scheme, host and port), and shows how a server opts in with an Access-Control-Allow-Origin header. You then play with a simulator: choose where the page lives, what it asks for and how the server is set up, and watch the browser decide, including preflight requests and cookies.

Diagram of a web page on app.example.com asking a server on api.example.com, which answers with an Access-Control-Allow-Origin header so the page can read the response

The error that says your server did nothing wrong

You write a page, call your own API with fetch, and the browser console answers: "blocked by CORS policy". Yet if you open the API address in a new tab, it works. The server is fine. So who blocked what?

The short answer: the browser did, on purpose, to protect the person using it. This article builds up the idea from one rule and lets you break it yourself.

Start with the origin

An origin is three things glued together: the scheme (https), the host (app.example.com) and the port (443 unless written). Two addresses are the same origin only if all three match. The path does not count.

PageCallsSame?
https://app.example.com/ahttps://app.example.com/byes (path ignored)
https://app.example.comhttp://app.example.comno (scheme)
https://app.example.comhttps://api.example.comno (host)
https://app.example.comhttps://app.example.com:8443no (port)

By default, JavaScript on one origin may send requests elsewhere but may not read the answers from another origin. Without that rule, any website you visit could quietly read your webmail in another tab, using your logged-in cookies.

CORS: the server opts in

CORS (Cross-Origin Resource Sharing) is the way a server says "yes, that other origin may read my answers". The conversation is made of ordinary HTTP headers:

  1. The browser adds Origin: https://app.example.com to the request.
  2. The server may reply with Access-Control-Allow-Origin: https://app.example.com.
  3. The browser compares. If the header matches the page's origin (or is *), your JavaScript gets the response. If not, it gets an error and nothing else.

Here is a tiny Node server that allows exactly one origin. This is the real output from running it and asking with curl, which pretends to be a page by sending an Origin header:

const http = require("http");
const ALLOWED = ["https://app.example.com"];

http.createServer((req, res) => {
  const origin = req.headers.origin;
  if (ALLOWED.includes(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Vary", "Origin");
  }
  if (req.method === "OPTIONS") {           // the preflight question
    res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE");
    res.setHeader("Access-Control-Allow-Headers", "Content-Type");
    res.writeHead(204);
    return res.end();
  }
  res.setHeader("Content-Type", "application/json");
  res.end(JSON.stringify({ hello: "from the API" }));
}).listen(3000);
Origin: https://app.example.com   ->  HTTP/1.1 200 OK
                                      Access-Control-Allow-Origin: https://app.example.com
                                      Vary: Origin

Origin: https://evil.example      ->  HTTP/1.1 200 OK
                                      (no Access-Control-Allow-Origin header)

Look at the second case: the server still answered 200 OK. It did the work and sent the data. The only thing missing is a header, and the browser uses that absence to hide the data from the page. curl, Postman and other servers ignore CORS completely, because it is a browser rule.

Try it: the browser's decision, step by step

Pick where the page lives, what it asks for, and how the server is set up. Then press Send and read what the browser decides.

Why some requests ask permission first

Notice that the DELETE and the JSON POST triggered an extra question. This is the preflight: before sending anything that could change data in unusual ways, the browser sends an OPTIONS request asking "would you accept this?". Only if the server's answer allows it does the real request go out.

A request skips the preflight when it is "simple": the method is GET, HEAD or POST, only a few ordinary headers are set, and the Content-Type is one that an old-fashioned HTML form could already send (application/x-www-form-urlencoded, multipart/form-data or text/plain). Plain forms could always post to other sites, so those requests are sent without a prior question. That is why the lab shows a blocked simple POST that the server still handled.

The preflight answer can be remembered with Access-Control-Max-Age (seconds), so the browser does not ask before every call. Browsers cap that number, and MDN lists 5 seconds as the default if you send none.

Cookies make it stricter

When a page asks the browser to include cookies (fetch(url, { credentials: "include" })), two extra rules apply. The server must send Access-Control-Allow-Credentials: true, and Access-Control-Allow-Origin must name the exact origin: * is refused. The reason is plain: "any site may read this person's logged-in data" is exactly what the original rule exists to stop.

What this means for your own code

When you see the error, the fix lives on the server, not in your page. Check the console message: it names the missing header. Then add an allow-list of origins you actually trust, as the sample does, rather than echoing back whatever Origin arrives. If you pick the origin from a list, also send Vary: Origin so a shared cache does not hand one origin's answer to another.

Two habits will save you hours. First, a CORS error is not proof the server is broken; look at whether the request reached it. Second, never treat CORS as the lock on your API: it only controls what other websites can read in a browser. Anyone with curl can still call your server, so authentication and permission checks still belong on the server.

$ ls web/

see all →
web/flexbox-how-a-row-of-boxes-decides-who-gets-the-leftover-space.md Flexbox: how a row of boxes decides who gets the leftover space Put three boxes in a row and the browser decides, by a short fixed recipe, who gets the extra room and who gives some up. Learn the three… 6 min read · 26 Oct web/css-specificity-why-your-style-is-ignored.md CSS specificity: why your style is ignored You write a style, reload the page, and the text is still the old colour. Nothing is broken: another rule is winning, and CSS decides with… 7 min read · 22 Oct web/the-dom-how-a-browser-turns-html-text-into-a-tree.md The DOM: how a browser turns HTML text into a tree A web page starts as plain text, yet a script can add a bullet to it while it is open. How? The browser never works on your text directly… 6 min read · 19 Oct web/cookies-how-a-website-remembers-you-between-requests.md Cookies: how a website remembers you between requests The web forgets you after every request, so how does a site know you are still logged in? With a cookie: a small name and value the server… 4 min read · 16 Oct web/event-bubbling-why-clicking-a-button-also-clicks-its-parent.md Event bubbling: why clicking a button also clicks its parent You click a button inside a div, and both click handlers run. Why does the parent hear about something that happened to its child? Every… 5 min read · 13 Oct web/http-requests-and-responses-what-a-404-and-a-301-really-say.md HTTP requests and responses: what a 404 and a 301 really say Every web page starts as two short pieces of plain text: a request from your browser and a response from a server. Learn to read them and… 4 min read · 10 Oct