A web page can send requests anywhere, but it can only read the answers from its own origin unless the other server says otherwise. CORS (Cross-Origin Resource Sharing) is the set of HTTP headers a server uses to say which other origins may read its responses, and once you know how it works, the most confusing error in front-end development becomes a quick fix.
What counts as an origin
An origin is three things together: the scheme (https), the host (shop.example.com) and the port (443 by default for HTTPS). Change any one of them and you are talking to a different origin. The path, the query string and the fragment don't count.
Compared with https://shop.example.com | Same origin? | Why |
|---|---|---|
https://shop.example.com/cart | Yes | Only the path differs |
http://shop.example.com | No | Different scheme |
https://api.example.com | No | Different host |
https://shop.example.com:8443 | No | Different port |
The third row catches people out most often. A shop front on shop.example.com and its API on api.example.com belong to the same company and the same domain, but to the browser they are two origins.
The same-origin policy comes first
Browsers apply the same-origin policy: a script running on one origin can send a request to another, but it can't read the response. The reason is cookies. When your browser sends a request to your bank, it can attach your bank's cookies even when another site's page started the request. Without the policy, any page you happened to visit could call your bank's API with your session and read your balance.
CORS is the controlled exception to that rule. The server opts in by naming the origins it trusts, and the browser lets only those origins read what comes back. Everyone else still gets blocked.
The browser enforces, the server decides
Two parties share the work, and mixing them up is behind most CORS confusion:
- The server decides. It sends
Access-Control-Allow-*response headers saying which origins, methods and headers it accepts. - The browser enforces. It adds an
Originheader to every cross-origin request, reads the server's answer and decides whether the page's script may see the response.
It follows that CORS only exists inside browsers. curl, Postman, a mobile app's HTTP client and your own back-end services ignore it completely. CORS protects users' browsers from other websites; it does not protect your API from anyone who calls it directly. That still needs authentication and authorisation.
Simple requests
Some requests are 'simple': a GET, HEAD or POST with only a handful of safe headers, and for a POST, a body of form data or plain text. These are the requests an ordinary HTML form could always send, so the browser sends them straight away with an Origin header:
GET /products HTTP/1.1
Host: api.example.com
Origin: https://shop.example.comThe server answers as normal and, if it trusts the shop, adds one header:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://shop.example.com
Content-Type: application/jsonThe value must be the calling origin exactly, or * for any origin. If the header is missing or doesn't match, the response still arrives at the browser, but the script never sees it: fetch rejects with a network error and the console shows the CORS message.
Notice the order: for a simple request, the server has already done the work before the browser checks. That is why CORS is not a defence against unwanted writes. Stopping a hostile site from triggering a form post with your cookies is the job of CSRF protection and SameSite cookies.
Preflight requests
Anything that isn't simple gets checked before it is sent. That covers PUT, PATCH and DELETE, a Content-Type of application/json, an Authorization header and any custom header. The browser first sends an OPTIONS request, the preflight, describing what it wants to do, and waits for the server's permission.
Here is the whole exchange for a cart update, from the page's fetch call to the response it can read:
A preflight, then the real cross-origin request
Step 1 of 7: The shop page calls fetch to PUT a JSON body to the API, which is a different origin.
The preflight answer can include Access-Control-Max-Age, which lets the browser cache the permission for a while so it doesn't ask again before every request.
A worked example
A shop front on https://shop.example.com updates the cart on https://api.example.com, sending the user's session cookie:
const res = await fetch(
"https://api.example.com/cart",
{
method: "PUT",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ sku: "MUG-1", qty: 2 }),
}
);The PUT and the JSON body each mean a preflight. The browser sends:
OPTIONS /cart HTTP/1.1
Host: api.example.com
Origin: https://shop.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-typeOn the API, an Express server using the cors middleware allows exactly that origin:
import express from "express";
import cors from "cors";
const app = express();
app.use(cors({
origin: ["https://shop.example.com"],
methods: ["GET", "PUT", "DELETE"],
credentials: true,
maxAge: 600,
}));Its answer to the preflight carries the permission:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Methods: GET,PUT,DELETE
Access-Control-Allow-Headers: content-type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600credentials: true matters here. When a request includes cookies, the browser only accepts the response if the server sends Access-Control-Allow-Credentials: true and names the origin exactly. A wildcard * is refused for credentialed requests.
Every framework has an equivalent: ASP.NET Core has a CORS policy you register at start-up, Django and Flask have extensions, and most API gateways and CDNs can add the headers for you. Configure it in one place rather than setting headers by hand in each route.
Common mistakes
- Trying to fix it in the front end. The error appears in the browser, but the fix is on the server, or in a proxy that serves the API from the page's own origin. Setting
mode: "no-cors"infetchdoesn't help: it returns an opaque response your script can't read. - Using
*with cookies. The browser rejects it. Name the origins instead. - Reflecting any
Originback. Copying whateverOriginarrives intoAccess-Control-Allow-Origin, with credentials allowed, lets every website read your logged-in users' data. Check the origin against an allowlist. - Blocking the preflight. Preflights carry no cookies or
Authorizationheader, so authentication middleware that runs first often rejects them with a 401, and every real request then fails. LetOPTIONSthrough to the CORS handler. - Forgetting
Vary: Origin. If the header's value changes with the caller, a shared cache could serve one origin's answer to another. Good CORS middleware addsVary: Originfor you. - Missing headers on error responses. A 500 without CORS headers shows up in the console as a CORS error, hiding the real failure. Check the server's logs, or the Network tab, before changing the CORS set-up.
When you don't need it
If the page and the API share an origin, there is nothing to configure. A common set-up puts the API behind the same domain at a path such as /api, with a reverse proxy sending those requests on. That avoids preflights altogether. Public, read-only data such as a font or an open dataset can safely use Access-Control-Allow-Origin: *, since no cookies are involved.
Key takeaways
- An origin is the scheme, host and port together; change any one and the request is cross-origin.
- The browser enforces CORS, and the server decides who is allowed through its
Access-Control-Allow-*headers. - Requests that aren't simple get an
OPTIONSpreflight before they are sent. - Fix CORS on the server with an allowlist of origins, and never pair
*with credentials. - CORS protects users in their browsers, not your API: it still needs authentication.