CORS and the Same-Origin Policy
Understand the browser's same-origin policy, how CORS relaxes it in a controlled way, what a preflight OPTIONS request checks, how allowed origins/headers/methods/credentials are configured, and the development mistakes that most commonly break a React app calling a separate API.
Almost every React developer eventually hits a red CORS error in the browser console when their frontend, running on one origin (say http://localhost:5173), tries to call an API on another origin (say http://localhost:8080 or https://api.example.com). Understanding CORS is understanding a browser security feature working as intended, not a bug to route around — the fix belongs on the server, and knowing exactly what the browser is checking turns a confusing error into a two-minute fix.
Origin and the same-origin policy
An origin is the combination of scheme (http/https), host (domain), and port. https://app.example.com:443 and http://app.example.com:80 are different origins because the scheme differs; https://app.example.com and https://api.example.com are different origins because the host differs, even though they share a parent domain; http://localhost:3000 and http://localhost:8080 are different origins because the port differs. All three parts must match exactly for two URLs to be same-origin.
The same-origin policy (SOP) is the browser's default rule: JavaScript running on one origin cannot read the response of a network request to a different origin, and cannot read another origin's DOM, cookies, or localStorage. This is not about whether the request can be *sent* — in many cases it can be — it's about whether the calling script is allowed to *read the result*. SOP is what stops a malicious site from silently reading your bank's API responses just because your browser happens to be logged in there.
SOP is a default that would make legitimate cross-origin API usage (a React SPA on one domain calling a REST API on another) impossible without an explicit exception mechanism — that mechanism is CORS.
How CORS relaxes SOP, and what the server controls
CORS (Cross-Origin Resource Sharing) is a set of HTTP response headers a server sends that tell the browser 'it's fine for JavaScript on origin X to read this response.' The browser still sends the request in many cases (see preflight below), receives the response, and then checks the CORS headers before deciding whether to hand that response to the calling JavaScript — if the headers don't authorize the calling origin, the browser throws away the response and your fetch()/axios call rejects with a CORS error, even though the request may have actually succeeded on the server.
This is the detail that trips up almost everyone the first time: a CORS error in the console does not necessarily mean the request failed server-side. For a non-preflighted request (see below), the server may have fully processed it — written to a database, sent an email — and it's only the *response* that the browser withholds from your JavaScript. This matters a lot for state-changing requests during debugging.
Simple requests vs. preflighted requests
Not every cross-origin request triggers an extra round trip. A 'simple request' — GET/HEAD/POST with only a small set of allowed headers (Accept, Accept-Language, Content-Language, and Content-Type limited to a few MIME types like application/x-www-form-urlencoded, multipart/form-data, or text/plain) — is sent directly, and the browser just checks the CORS response headers before releasing the response to JavaScript.
Anything outside that narrow definition — a PUT/DELETE/PATCH method, a JSON body (Content-Type: application/json is not in the simple list), or a custom header like Authorization or X-CSRF-Token — triggers a preflight: before sending your actual request, the browser automatically sends an OPTIONS request to the same URL, asking 'if I were to send this real request, would you allow it?' The OPTIONS request includes Access-Control-Request-Method and Access-Control-Request-Headers describing what the real request will look like.
The server must respond to that OPTIONS request with headers describing what it permits: Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers, and optionally Access-Control-Allow-Credentials and Access-Control-Max-Age (how long the browser may cache this preflight result). Only if the preflight response authorizes the origin, method, and headers does the browser then send the actual request at all — this is a browser-enforced gate, and a React app doing a JSON POST to a new API for the first time will always show an OPTIONS request in the network tab immediately before it.
• Simple request: GET/HEAD/POST, only simple headers → sent directly, response gated by CORS headers. • Preflighted request: PUT/DELETE/PATCH, JSON body, custom headers (Authorization, etc.) → OPTIONS sent first. • The browser will not even attempt the real request if the OPTIONS preflight response doesn't authorize it. • Access-Control-Max-Age lets the browser cache a preflight result and skip repeating the OPTIONS round trip for a while.
Allowed origins, headers, methods, and credentials
Access-Control-Allow-Origin names which origin(s) may read the response. It can be a specific origin (https://app.example.com), the wildcard * (any origin), or dynamically computed per-request from an allowlist (the server checks the incoming Origin header against a list and echoes it back only if it matches, plus a Vary: Origin header so caches don't serve one origin's allowed response to another).
Access-Control-Allow-Methods lists which HTTP methods are permitted (GET, POST, PUT, DELETE, ...); Access-Control-Allow-Headers lists which request headers the browser may send (Content-Type, Authorization, X-Requested-With, custom headers your app uses). Both are checked against what the preflight's Access-Control-Request-Method/Headers asked for.
Access-Control-Allow-Credentials: true is required if your fetch/axios call sets credentials: 'include' (to send cookies cross-origin) — and critically, when credentials are involved, Access-Control-Allow-Origin can never be the wildcard *; it must name a specific origin. Browsers enforce this pairing rule and will block the response even if the server tries to send both '*' and 'true' together.
• Access-Control-Allow-Origin: <origin> or * (never * when credentials are used) • Access-Control-Allow-Methods: GET, POST, PUT, DELETE, ... • Access-Control-Allow-Headers: Content-Type, Authorization, ... • Access-Control-Allow-Credentials: true (required for credentials: 'include'; forbids wildcard origin) • Vary: Origin — required when the allowed origin is computed dynamically, so shared caches don't leak one origin's response to another
Common development mistakes
The single most common mistake is trying to 'fix' a CORS error entirely on the frontend — adding mode: 'no-cors' to a fetch call makes the error disappear from the console, but it does so by making the response opaque (unreadable) to your JavaScript, which is almost never what you actually want; you cannot read status, headers, or body from a no-cors response. CORS is fixed on the server that owns the response, not by frontend configuration.
A second common mistake is setting Access-Control-Allow-Origin: * while also trying to send cookies with credentials: 'include' — browsers reject this combination outright, and the fix is to name the specific origin explicitly (and often to reflect the request's Origin header against a server-side allowlist for multiple valid frontend origins like localhost during dev and the real domain in production).
A third is forgetting that changing your dev server's port (React on 5173, API on 3000) makes every single request cross-origin during local development even though both sit on localhost — a very common source of 'it works in this other project but not here' confusion, usually solved either by configuring CORS properly on the dev API or by using your frontend dev server's proxy feature (e.g. Vite's server.proxy) to make requests same-origin from the browser's perspective.
A fourth is forgetting to handle the OPTIONS method on the server at all — if your API framework only defines routes for GET/POST/etc. and returns 404 or 405 for OPTIONS, the preflight fails and the browser never sends the real request, even though your actual endpoint logic is perfectly correct.
• Using mode: 'no-cors' to silence the error instead of fixing the server's CORS headers. • Combining Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true (browsers reject this pairing). • Forgetting that different localhost ports are different origins during local dev. • Not implementing/allowing the OPTIONS method server-side, so preflights fail before the real request is ever sent. • Adding a custom header (e.g. a tracing header) without adding it to Access-Control-Allow-Headers, silently breaking only the preflighted requests.
A minimal CORS-aware fetch from a React app, and a Vite dev-server proxy
With the proxy, the browser sees requests to /api/* as same-origin to the Vite dev server; Vite itself forwards them server-to-server, where CORS (a browser-only concept) does not apply at all. The proxy is a development convenience — production still needs correct CORS headers on the real API unless you deploy the frontend and API behind the same origin (e.g. a reverse proxy) there too.
// React app calling a separate-origin API with credentials:
async function getProfile() {
const res = await fetch("https://api.example.com/me", {
credentials: "include", // requires server Access-Control-Allow-Credentials: true
headers: { "Content-Type": "application/json" },
});
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
}
// vite.config.ts — proxy during development to avoid CORS entirely in dev,
// since the browser then only ever talks to your Vite origin.
export default defineConfig({
server: {
proxy: {
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
},
},
},
});Code Examples
You never write the OPTIONS request yourself — the browser generates it automatically whenever a request falls outside the 'simple request' rules, purely to ask the server's permission before sending the real one.
Access-Control-Request-Method: POST Only after receiving this response does the browser send the actual POST request. Access-Control-Max-Age: 600 lets the browser skip repeating this preflight for the same URL/method/headers combination for 10 minutes.
This is a browser-enforced rule, not a server bug you can work around from the frontend — the fix must happen server-side by naming the exact calling origin instead of using the wildcard.
Common mistake: Wildcard origin plus credentials is rejected by the browser regardless of any frontend fetch configuration.
Naming the exact origin (echoed only when it matches a known allowlist) instead of '*' satisfies the browser's credentials rule while still supporting multiple legitimate frontend origins like local dev and production.
async function createPost(payload: { title: string; body: string }) {
// Content-Type: application/json + method POST is NOT a "simple request"
// because application/json is outside the simple Content-Type list,
// so the browser sends an OPTIONS preflight first, automatically.
const res = await fetch("https://api.example.com/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
return res.json();
}
// Browser automatically sends, before the POST above:
// OPTIONS /posts HTTP/1.1
// Origin: https://app.example.com
// Access-Control-Request-Method: POST
// Access-Control-Request-Headers: content-typeHTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600// Server (BROKEN):
// Access-Control-Allow-Origin: *
// Access-Control-Allow-Credentials: true
fetch("https://api.example.com/me", { credentials: "include" });
// Browser console:
// "The value of the 'Access-Control-Allow-Origin' header in the response
// must not be the wildcard '*' when the request's credentials mode is
// 'include'."const ALLOWED_ORIGINS = new Set([
"http://localhost:5173",
"https://app.example.com",
]);
function applyCors(req: Request, res: Response) {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Vary", "Origin");
}
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization");
}React App to different-origin API: the CORS decision flow
The server always responds; the browser is the one deciding, based on the response headers, whether your JavaScript is allowed to see the result.
React App (https://app.example.com)
|
| fetch("https://api.example.com/...")
v
Different-Origin API (https://api.example.com)
|
| [if not a "simple request"]
| Browser first sends: OPTIONS + Access-Control-Request-*
v
CORS response headers
Access-Control-Allow-Origin
Access-Control-Allow-Methods
Access-Control-Allow-Headers
Access-Control-Allow-Credentials
|
v
Browser access decision
headers match calling origin/method/headers?
YES -> real request sent (if preflighted) / response handed to JS
NO -> request blocked or response withheld; console CORS errorImportant Rules
Origin = scheme + host + port, all three must match exactly for same-origin.
CORS is fixed on the server that owns the response — no frontend configuration can grant a browser permission the server headers don't.
Any method outside GET/HEAD/simple-POST, a JSON body, or a custom header triggers an automatic OPTIONS preflight.
Access-Control-Allow-Origin can never be '*' when Access-Control-Allow-Credentials: true is also set — browsers enforce this pairing.
Use an explicit origin allowlist (echoed with Vary: Origin) rather than a wildcard whenever credentials are involved or multiple known frontends exist.
During local development, prefer a dev-server proxy (e.g. Vite's server.proxy) over loosening production CORS rules just to silence dev errors.
Why this matters beyond the demo app
CORS misconfiguration is one of the most frequently reported classes of finding in frontend security audits, precisely because the fix that makes local development errors disappear fastest (wildcard origins, no-cors mode, disabling credential checks) is also the fix that reintroduces the exact cross-origin data-exposure risk CORS exists to prevent — teams under deadline pressure often ship the quick fix to production.
fetch(url, { mode: 'no-cors' }) to make the console error go away
Fix Access-Control-Allow-Origin/-Methods/-Headers on the server that serves the API no-cors produces an opaque response your JavaScript cannot read (no status, headers, or body access) — it hides the symptom without solving the underlying access problem.
Access-Control-Allow-Origin: * with credentials: 'include' on the client
Access-Control-Allow-Origin: <exact origin>, computed from an allowlist, with Access-Control-Allow-Credentials: true
Browsers refuse to expose the response to JavaScript when the wildcard origin is paired with credentials, regardless of any other configuration.
API server has no route/handler for the OPTIONS method Explicitly handle or allowlist OPTIONS (many frameworks/CORS middleware do this automatically once configured)
If the preflight OPTIONS request 404s or 405s, the browser treats the preflight as failed and never sends the real request, even when the real endpoint's logic is entirely correct.
Which pair of URLs are considered the same origin by the browser? A. http://example.com and https://example.com B. https://example.com:443 and https://example.com C. https://app.example.com and https://api.example.com D. http://localhost:3000 and http://localhost:8080
B. https://example.com:443 and https://example.com — 443 is the default HTTPS port, so https://example.com and https://example.com:443 refer to the identical origin (same scheme, host, and effective port); the other pairs differ in scheme, host, or port.
What triggers the browser to send an automatic OPTIONS preflight request before the real one? A. Every single cross-origin request, with no exceptions B. A request that falls outside the narrow 'simple request' definition — e.g. a PUT/DELETE method, a JSON body, or a custom header C. Only requests that include cookies D. Only requests made with axios instead of fetch
B. A request that falls outside the narrow 'simple request' definition — e.g. a PUT/DELETE method, a JSON body, or a custom header — Simple GET/HEAD/POST requests with only simple headers skip the preflight; anything outside that (custom methods, JSON content-type, custom headers like Authorization) triggers an OPTIONS check first.
Why does the browser reject Access-Control-Allow-Origin: * when a request is sent with credentials: 'include'? A. Wildcard origins are not valid HTTP header syntax B. Because combining an unrestricted origin with credentialed access would let any site on the internet read authenticated, cookie-bearing responses C. The wildcard only works with GET requests D. It's a limitation of the fetch API, not a browser security rule
B. Because combining an unrestricted origin with credentialed access would let any site on the internet read authenticated, cookie-bearing responses — Allowing every origin to read a credentialed (cookie-authenticated) response would defeat the purpose of same-origin protections entirely, so browsers specifically forbid pairing the wildcard origin with Access-Control-Allow-Credentials: true.
A CORS error appears in the console for a POST request. Does this necessarily mean the server never processed the request? A. Yes, a CORS error always means the server rejected the request outright B. No — for a non-preflighted simple request, the server may have fully processed it; the browser only withheld the response from the calling JavaScript C. Yes, because OPTIONS requests always fail before any real request is attempted D. CORS errors only occur for GET requests, never POST
B. No — for a non-preflighted simple request, the server may have fully processed it; the browser only withheld the response from the calling JavaScript — For simple requests, the browser sends the request and receives the response, but then checks CORS headers before deciding whether to expose that response to your script — the server-side effect (e.g. a database write) may have already happened even though your code sees only an error.
Your React app at https://app.example.com calls https://api.example.com/orders with a PUT request, a JSON body, and credentials: 'include'. In the network tab you see an OPTIONS request that returns 200 with Access-Control-Allow-Origin: https://app.example.com and Access-Control-Allow-Methods: GET, POST but the actual PUT request never fires and the console shows a CORS error. What's wrong, and what header value needs to change?
Access-Control-Allow-Methods does not include PUT, so the browser's preflight check fails and it refuses to send the real PUT request at all. The server needs to add PUT (e.g. Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS) to its preflight response.
Compare what the preflight authorizes against what the real request actually needs — specifically the method.