What Is CORS? 7 Things Every Developer Should Know (With Preflight Examples)

October 15, 2026 · Web Development

You wire up a frontend, point fetch() at your API, and the browser answers with red text: "blocked by CORS policy." The API works fine in Postman. The endpoint exists. Nothing in your code is wrong — yet the request fails. What is CORS? Cross-Origin Resource Sharing is the browser mechanism that decides when JavaScript on one origin may read responses from a server on a different origin. This guide covers why the rule exists, what counts as an origin, simple vs preflighted requests, credentials, every CORS header that matters, and how to fix the error for real.

Diagram of a browser requesting data from a server on a different domain, with a shield checkmark between them
CORS in one picture: the browser asks, the server answers with headers, and only then does JavaScript get to read the response.

What is CORS?

CORS (Cross-Origin Resource Sharing) is a standard mechanism, defined in the WHATWG Fetch Living Standard, that lets a server tell browsers which origins are allowed to read its responses from JavaScript. It works by adding HTTP headers — Access-Control-Allow-Origin and friends — to responses. The browser enforces the decision; the server only declares its policy.

The most common misunderstanding is that CORS blocks requests. It usually does not. For most cross-origin requests, the browser still sends the request — it blocks your page's JavaScript from reading the response when the server has not opted in. That is why the same API call succeeds in curl or Postman: those tools are not browsers, so CORS never applies to them. If your API returns data to curl but your frontend sees a CORS error, the server is fine — its CORS headers are missing.

Why browsers block cross-origin reads: the same-origin policy

CORS exists because of an older, stricter rule: the same-origin policy. By default, a script running on one origin cannot read responses from another origin. Imagine you are logged into your bank, then open a malicious site in another tab. Without the same-origin policy, that site's JavaScript could silently fetch() your bank's pages and read the responses — including account data — using your logged-in session cookies.

The same-origin policy closes that attack completely, but it also blocks legitimate work: every modern app with a frontend on one domain and an API on another would break. CORS is the controlled exception — a way for a server to say "this specific origin is allowed to read my responses." Think of the same-origin policy as the lock and CORS as the guest list.

What counts as an "origin"?

An origin is the combination of three parts: protocol + domain + port. All three must match exactly, or the origins are different:

  • https://app.example.com vs http://app.example.com — different protocol, different origin.
  • https://app.example.com vs https://api.example.com — different subdomain, different origin (subdomains always count as different).
  • http://localhost:3000 vs http://localhost:8080 — different port, different origin. This is why a React app on port 3000 calling an API on port 8000 hits CORS in development.

The browser attaches the calling page's origin to every cross-origin request in an Origin header. The server reads it and decides whether to allow that origin in its response.

Diagram of three interlocking puzzle pieces representing protocol, domain, and port — the three parts of a web origin
An origin is three pieces: protocol, domain, and port. Change any one and it is a different origin.

How CORS works: simple requests

Some requests do not trigger a preflight. These are called simple requests, and they must meet all of these conditions (per MDN's CORS reference):

  • Method is GET, HEAD, or POST.
  • Only CORS-safelisted headers are set manually: Accept, Accept-Language, Content-Language, Content-Type, and Range (single range only).
  • Content-Type, if present, is one of application/x-www-form-urlencoded, multipart/form-data, or text/plain — the three types an HTML <form> can produce.
  • No ReadableStream body, and no upload-progress listeners on the request.

The logic: a plain HTML form could already send such a request cross-origin in 1999, so servers must already defend against it (that defense is CSRF tokens). For a simple request, the browser sends the real request immediately, then checks the response headers before handing the data to JavaScript:

GET /resources/public-data/ HTTP/1.1
Host: bar.other
Origin: https://foo.example

HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Content-Type: application/xml
[…XML Data…]

Access-Control-Allow-Origin: * means any origin may read this response. To restrict it to one site, the server echoes that origin instead: Access-Control-Allow-Origin: https://foo.example. If the header is missing entirely, the browser throws the CORS error — even though the server returned a 200.

Preflight requests: the OPTIONS handshake

Anything that is not a simple request gets preflighted: before the real request, the browser sends an OPTIONS request asking permission, and only proceeds if the server approves. A POST with a Content-Type: application/json body is the classic trigger — JSON is not one of the three form types, so the browser preflights:

OPTIONS /doc HTTP/1.1
Host: bar.other
Origin: https://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type,x-pingother

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400

The preflight carries no body and changes nothing — it is a question. The response answers three things: which origins may proceed, which methods are allowed, and which headers are allowed. Only then does the browser send the real POST. Access-Control-Max-Age tells the browser how long to cache that permission (in seconds) instead of preflighting every request; the default is 5 seconds, and browsers cap it — Firefox at 24 hours (86400s), Chromium at 2 hours (7200s).

Diagram of a preflight flow: a small dashed question arrow from browser to server, followed by a larger solid arrow carrying data
Preflight first, real request second: the browser asks permission with OPTIONS, then sends the actual request only if approved.

CORS with credentials: cookies and auth

By default, cross-origin fetch() sends no cookies. To include them, the client opts in with credentials: 'include' — and the server must cooperate with two rules:

  • The response must include Access-Control-Allow-Credentials: true.
  • Access-Control-Allow-Origin must name the exact requesting origin. The * wildcard is rejected for credentialed requests — the server must echo the origin, typically with a Vary: Origin header so caches do not mix up responses for different origins.

This is the setup for session-cookie APIs and same-site frontend/backend pairs. If you see a CORS error only on authenticated calls while public calls work, a wildcard origin paired with credentials is the usual culprit.

The 6 CORS headers that matter

HeaderSent byWhat it does
OriginBrowser (request)Names the calling page's origin so the server can decide.
Access-Control-Allow-OriginServer (response)Which origins may read the response: * or one exact origin.
Access-Control-Allow-MethodsServer (preflight)Which methods (GET, POST, PUT…) are permitted cross-origin.
Access-Control-Allow-HeadersServer (preflight)Which request headers (e.g. Authorization, Content-Type) are permitted.
Access-Control-Allow-CredentialsServer (response)true permits cookies/auth on cross-origin requests; requires an exact origin, never *.
Access-Control-Expose-HeadersServer (response)Which extra response headers JavaScript may read (e.g. X-Total-Count).

One more worth knowing: Access-Control-Max-Age (preflight cache lifetime). And when a server echoes the request origin, it should also send Vary: Origin so shared caches store a separate copy per origin.

Fixing CORS in real projects

Fix it on the server — that is the only real fix. CORS headers must come from the API, because the browser trusts only the server's answer. Three common setups:

Express (Node.js) — the cors middleware. Never use the default app.use(cors()) (open to every origin) on an API that handles real data; name your origin:

const cors = require('cors');
app.use(cors({ origin: 'https://app.example.com' }));

Flask (Python) — Flask-CORS, same idea:

from flask_cors import CORS
CORS(app, origins=["https://app.example.com"])

Django — django-cors-headers, configured in settings:

CORS_ALLOWED_ORIGINS = ["https://app.example.com"]

Debugging checklist when it still fails: (1) open DevTools → Network, find the failing request (often a red OPTIONS), and check whether Access-Control-Allow-Origin is present in the response headers; (2) compare the Origin request header against what the server allows — a trailing slash or www mismatch fails the match; (3) if the preflight fails, fix the preflight before touching the endpoint; (4) verify with curl -H "Origin: https://app.example.com" -v https://api.example.com/data — curl shows the raw headers with no browser in the way. You can also inspect any live endpoint's headers with our HTTP header checker.

When you cannot change the API, the legitimate escape hatch is a same-origin proxy: your frontend calls your own backend, and your backend calls the API server-to-server (no CORS, since CORS is browser-only). Vite, webpack dev server, and Next.js rewrites all support this pattern. It keeps secrets on the server and removes the cross-origin hop entirely. What you should not ship: a browser extension that disables CORS, or asking users to launch Chrome with security flags off.

Diagram comparing two paths: one straight direct arrow for a simple request, and one arrow passing through a checkpoint gate for a preflighted request
Simple requests go straight through; everything else stops at the preflight checkpoint first.

5 CORS myths debunked

  1. "CORS is a server-side firewall." No — the server always answers; the browser decides whether JavaScript sees the answer. Any non-browser client reads everything.
  2. "POST is blocked by CORS, GET is not." A simple POST (form-encoded, no custom headers) is sent without a preflight, just like GET. What triggers a preflight is the shape of the request, not the method alone.
  3. "Adding * fixes everything." It fixes public, credential-free reads. It fails the moment cookies or auth are involved, and it is the wrong default for any API with user data.
  4. "CORS stops CSRF." It does not. Simple cross-origin requests are still sent — that is exactly what a CSRF attack is. CSRF tokens and SameSite cookies stop CSRF; CORS does not.
  5. "If it works in Postman, the frontend code is buggy." Postman never enforces CORS. A CORS error with a working Postman call points at missing server headers, not at your fetch() call.

Try it yourself

  • curl -H "Origin: https://app.example.com" -v https://api.example.com/data — look for Access-Control-Allow-Origin in the response headers.
  • Trigger a preflight: fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }) from a page on a different origin, then watch the OPTIONS request in DevTools → Network.
  • Paste any API URL into our HTTP header checker to see which CORS headers it actually returns.
  • Read the companion guides: what a REST API is (where CORS errors usually appear), client vs server, HTTP vs HTTPS, and what a webhook is — webhooks are server-to-server, so CORS never applies to them.

CORS is not an error to work around — it is the browser asking the server one question: "Is this web app allowed to read your response?" If the server does not answer with headers, the browser hides the data, even when the API returned a 200. Learn to read the Network tab, fix the headers or proxy through your backend, and this error stops wasting your afternoons. References: MDN — Cross-Origin Resource Sharing (CORS) and the WHATWG Fetch Living Standard. Next, read the HTTP status codes cheat sheet to decode what the server actually answered.

Frequently asked questions

What does CORS stand for?
CORS stands for Cross-Origin Resource Sharing. It is the browser mechanism that lets a web page on one origin (protocol + domain + port) read responses from a server on a different origin — but only when that server explicitly allows it with CORS response headers.
Why am I getting a CORS error if my API works in Postman?
Because CORS is enforced by the browser, not the server. Postman, curl, and server-side code never apply CORS, so they always see the full response. The browser blocks your page's JavaScript from reading the response when the server does not send an Access-Control-Allow-Origin header permitting your origin.
What is a CORS preflight request?
A preflight is an automatic OPTIONS request the browser sends before a cross-origin request that is not "simple" — for example, a POST with a JSON body or a custom header. It asks the server which methods and headers are allowed. Only if the server approves does the browser send the real request.
Can I use Access-Control-Allow-Origin: * with cookies?
No. When a request includes credentials (cookies, HTTP authentication), the server must return the exact requesting origin in Access-Control-Allow-Origin and include Access-Control-Allow-Credentials: true. The * wildcard is rejected for credentialed requests.
Does CORS protect my API from attackers?
Only from browser-based JavaScript reading responses. CORS does not stop anyone from calling your API with curl, Postman, or a server. It also does not stop CSRF: simple cross-origin requests are still sent. Real API protection needs authentication, authorization, and CSRF tokens — CORS is one layer, not the whole wall.
How do I fix "blocked by CORS policy" in development?
The real fix is server-side: add the CORS headers on the API, or proxy the API through your dev server so the browser sees a same-origin request (Vite, webpack dev server, and Next.js all support this). Browser extensions that disable CORS are a debugging shortcut, never a fix to ship.
What is the difference between CORS and the same-origin policy?
The same-origin policy is the browser's default rule: scripts on one origin cannot read responses from another origin. CORS is the controlled exception — the mechanism by which a server tells the browser "this origin is allowed to read my responses." Same-origin policy is the lock; CORS is the guest list.
Why can my JavaScript not read a custom response header like X-Total-Count?
Because browsers only expose a small safelist of response headers to cross-origin JavaScript (Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma). To expose anything else, the server must list it in Access-Control-Expose-Headers.

Related articles

Try the free tool