Your fetch call is correct. The server received it, ran the handler, and sent back a 200 with a body. Your JavaScript never sees any of that — instead the console prints a red line about Access-Control-Allow-Origin, and the promise rejects with a TypeError: Failed to fetch that tells you nothing about what actually went wrong. You didn't hit a bug in your code. You hit CORS, and the fix is one line — once you know which line.
What you'll learn
By the end of this guide you'll be able to:
- Explain, precisely, what the browser is and isn't protecting when it enforces CORS
- Tell whether a given
fetchwill be sent as a "simple" request or trigger a preflightOPTIONSround trip, before you open dev tools - Read a CORS console error and map it to the exact response header that's missing or wrong
- Configure a server (with or without a framework) to allow the right origins, methods, and credentials — and only those
- Avoid the two mistakes that cause the most CORS incidents: an unscoped wildcard, and combining
*with credentials
Who this is for: you've written a fetch or axios call to an API on a different host and hit an error you fixed by copy-pasting a header without fully understanding why it worked.
Contents
- Why CORS exists
- The mental model
- Stage 1: what makes two URLs the same origin
- Stage 2: simple requests vs. preflighted requests
- Stage 3: reading and answering a preflight
- Stage 4: credentials, cookies, and the wildcard trap
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
- Key takeaways
Why CORS exists
Here's the naive expectation: if a server responds to a request, the calling JavaScript should be able to read the response. That's how requests to your own domain behave, so it's a reasonable thing to assume — until you point fetch at a different origin.
// running on https://app.example.com
fetch("https://api.other-service.com/profile")
.then((res) => res.json())
.then((data) => console.log(data))
.catch((err) => console.error(err));
// Console:
// Access to fetch at 'https://api.other-service.com/profile' from origin
// 'https://app.example.com' has been blocked by CORS policy: No
// 'Access-Control-Allow-Origin' header is present on the requested resource.
Open the Network tab while this runs and the request often shows a 200 with a full JSON body sitting right there. The server did its job. The browser is the one refusing to hand that body to your script, and it does this on purpose: without this check, any site you happen to have open in a tab could quietly issue authenticated requests to your bank, your email provider, or your company's internal API — using cookies your browser is already sending for you — and read the responses. This is the same-origin policy, and it has protected the web since the mid-1990s. CORS (Cross-Origin Resource Sharing) is not a separate restriction bolted on top of it — it's the mechanism a server uses to selectively relax that policy for origins it trusts.
The mental model: CORS is the browser enforcing a promise the server makes
The mental model: CORS does not stop your request from reaching the server. It stops your JavaScript from reading a cross-origin response, unless the server's response headers explicitly say that origin is allowed to see it. For a large class of requests, the request is sent, the server executes it, and only the read-back is blocked.
That last sentence is the part almost everyone gets backwards, and it has a real consequence: if your cross-origin request is a POST that writes to a database, and the response is missing the right CORS header, the write still happened. Your JavaScript just never finds out it succeeded. A "CORS error" in the console is not evidence that nothing happened server-side — it's evidence that the browser hid the outcome from you.
Picture it as a two-party handshake with the browser standing in the middle:
- Your page, on origin A, asks the browser to fetch a resource on origin B.
- The browser sends the request (sometimes after first asking permission — more on that below).
- Origin B's response comes back with headers like
Access-Control-Allow-Origin: https://app.example.com. - The browser checks that header against the page's own origin. Match → your JavaScript gets the response. No match, or no header at all → the browser throws the response away and your
fetchpromise rejects.
Origin B never has to know your page exists in advance. It just has to say, in every response, which origins it's willing to let read that response. That's the entire protocol.
Stage 1: what makes two URLs the same origin
An origin is the triple of scheme + host + port. Change any one of the three and you have a different origin, even if the URLs look nearly identical:
| URL | Same origin as https://app.example.com:443? |
Why |
|---|---|---|
https://app.example.com/settings |
✅ Yes | Different path, same scheme/host/port |
http://app.example.com |
❌ No | Different scheme (http vs https) |
https://api.example.com |
❌ No | Different host (subdomain counts) |
https://app.example.com:8443 |
❌ No | Different port |
This trips people up locally more than in production: http://localhost:3000 (your frontend dev server) and http://localhost:8080 (your backend) are different origins, because the port differs. That "works on my machine, breaks in the console" moment during local development is usually this, not a real production concern — though you still need the same CORS configuration in dev as you'll need once the frontend and API genuinely live on different hosts. If you parse or compare origin values yourself rather than trusting req.headers.origin blindly, parsing URLs safely is worth getting right — a malformed or attacker-controlled origin string is not something you want to string-match casually.
Stage 2: simple requests vs. preflighted requests
Not every cross-origin request behaves the same way. The Fetch specification defines a "simple request": one the browser sends directly, with no extra round trip, because it matches a narrow safelist:
- Method is
GET,HEAD, orPOST. - The only headers you set by hand are ones on the CORS-safelisted list —
Accept,Accept-Language,Content-Language, orContent-Type(see below). - If
Content-Typeis set, its value is one of exactly three:text/plain,multipart/form-data, orapplication/x-www-form-urlencoded.
Notice what's missing from that list: application/json. This is the single most common surprise in CORS debugging. A request that looks simple —
fetch("https://api.example.com/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item: "widget" }),
});
— does not qualify, because application/json isn't one of the three safelisted content types. The browser has to find out, before sending your POST, whether the server accepts it. So it sends a preflight: a separate OPTIONS request, automatically, that your code never sees or triggers directly.
Key concept: the preflight isn't extra ceremony the spec invented to slow you down — it exists because a
POSTwith an arbitrary JSON body and custom headers is exactly the shape of request that could carry side effects (write a row, charge a card), so the browser confirms permission before letting that request out the door, rather than after the fact like it does for simple requests.
Stage 3: reading and answering a preflight
Open the Network tab on the JSON POST above and you'll see two requests, not one. First, the browser-generated preflight:
OPTIONS /orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
The server must answer with headers that explicitly cover what was asked:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 600
Only if every requested method and header is covered does the browser send the real POST. Here's the same thing wired up with Express, which is the shape you'll write by hand before reaching for a library:
import express from "express";
const app = express();
app.use((req, res, next) => {
const allowedOrigin = "https://app.example.com";
res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
res.setHeader("Vary", "Origin"); // see "Edge cases" — required when reflecting one origin
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
res.setHeader("Access-Control-Max-Age", "600"); // cache the preflight result (seconds)
if (req.method === "OPTIONS") {
return res.sendStatus(204); // preflight gets no body, just the headers above
}
next();
});
app.post("/orders", express.json(), (req, res) => {
res.json({ ok: true, item: req.body.item });
});
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
Almost nobody hand-writes this in production — the cors npm package does exactly this with a friendlier config surface — but understanding this middleware means you can read what any library is doing under the hood, and debug it when it isn't doing what you expect.
Stage 4: credentials, cookies, and the wildcard trap
By default, fetch does not send cookies on a cross-origin request. If your API relies on a session cookie, you opt in explicitly:
fetch("https://api.example.com/me", { credentials: "include" });
The moment you do this, two things become mandatory on the server side, and getting either wrong produces a CORS error that looks identical to the plain missing-header case:
res.setHeader("Access-Control-Allow-Origin", "https://app.example.com"); // NOT "*"
res.setHeader("Access-Control-Allow-Credentials", "true");
Access-Control-Allow-Origin: * and credentials are mutually exclusive by design in the CORS spec — a wildcard means "any origin may read this," and combined with cookies that would mean any site on the internet could read a logged-in user's data through your API. Browsers refuse to honor * alongside Access-Control-Allow-Credentials: true; you must name the exact origin. This is the second most common CORS incident after the Content-Type preflight surprise: someone sets * to "just make CORS work," it does — until credentials get added later, and it silently stops working with no code change on the client at all.
Edge cases and gotchas
CORS is enforced by the browser, not the server. A request from curl, Postman, a mobile app, or another backend service ignores CORS headers entirely — there's no JavaScript execution context for the browser to protect. "It works in Postman but not in my web app" is not a contradiction; it's the expected outcome, because Postman was never subject to this check in the first place.
Reflecting an origin requires Vary: Origin. If your server supports multiple allowed origins by checking the incoming Origin header against an allowlist and echoing it back, you must also send Vary: Origin. Without it, a shared cache (a CDN, or even the browser's own HTTP cache) can serve a response with origin A's Access-Control-Allow-Origin value to a request from origin B, silently breaking CORS for one origin while it looks fine for the other.
Access-Control-Max-Age has a browser-enforced ceiling. You can ask the browser to cache a preflight result for a long time, but browsers cap how long they'll actually honor it regardless of the value you send (Chromium currently caps it well under 24 hours). Don't assume a large number eliminates preflight traffic entirely — measure it.
Redirects are re-checked. If a cross-origin response is a redirect to another cross-origin URL, the CORS check applies again at the final destination. A misconfigured redirect target produces a CORS failure that looks like it's coming from the URL you called, when the actual problem is the URL you were redirected to.
mode: "no-cors" doesn't bypass CORS — it neuters the response. Setting fetch(url, { mode: "no-cors" }) lets the request go out without a CORS check, but the response comes back "opaque": your code can't read its status, headers, or body. It's useful for fire-and-forget beacons, not for anything you need data back from.
Same-origin is not the same as same-site. app.example.com and api.example.com are different origins (different host) but the same site (same registrable domain). This distinction matters for cookie SameSite behavior, which is a related but separate mechanism from CORS — don't conflate the two when a cookie isn't showing up where you expect it.
Best practices
Reach for a real allowlist in production. Keep an array or Set of exact allowed origins, check the incoming Origin header against it, and reflect only a match — never a bare * — once cookies, Authorization headers, or any sensitive data are involved.
Avoid it when you're building a public, read-only, unauthenticated API. If there are no credentials and no sensitive data, Access-Control-Allow-Origin: * is legitimate and simpler than maintaining an allowlist — that's exactly what public CDNs and open data APIs use.
Prefer a bearer token over a cookie for cross-origin auth when you can. An Authorization: Bearer <token> header sidesteps the credentials-and-wildcard interaction entirely, because tokens aren't sent automatically the way cookies are — you attach them explicitly per request, which also sidesteps CSRF concerns that cookie-based auth invites.
Set Access-Control-Max-Age deliberately on hot endpoints. A chatty preflight on every request to a high-traffic endpoint is a real, measurable cost; caching it for even a few minutes removes a full round trip from most calls.
Don't try to catch a CORS failure and inspect it in JavaScript. By design, the browser gives your catch block almost no detail — a generic TypeError: Failed to fetch, not "missing Access-Control-Allow-Origin." The Network tab and console are where the real diagnosis happens, not error.message. This is also why pairing a cross-origin call with an AbortController is worth doing regardless of CORS — a request that's blocked from being read by the browser can still be left running server-side until something cancels it.
Firing several cross-origin requests at once has the same CORS rules per-request. Each origin gets its own preflight and its own header check — there's no batching. If you're using Promise.allSettled or Promise.any to run several requests concurrently, expect one bad CORS config on a single endpoint to fail only that request, not the whole batch.
FAQ
Why does my request work in Postman but fail in the browser?
Because CORS is a browser-only protection. Postman, curl, and server-to-server calls have no same-origin policy to enforce, so they never send a preflight and never check response headers. Only requests initiated by JavaScript running in a browser page are subject to CORS.
Can I just set Access-Control-Allow-Origin: * to fix a CORS error?
It fixes the error, but only use it when the endpoint returns no sensitive, credential-gated data. * cannot be combined with Access-Control-Allow-Credentials: true — browsers reject that combination — so it isn't an option at all once cookies or auth headers are involved.
Does CORS protect my server from unauthorized access?
No. CORS is enforced entirely by the requesting browser; a non-browser client can ignore it completely and hit your API directly. It protects browser users from malicious pages reading data on their behalf — it is not authentication, and it is not a substitute for checking credentials server-side.
Why did adding a Content-Type: application/json header suddenly trigger a preflight?
Because application/json isn't one of the three CORS-safelisted content-type values (text/plain, multipart/form-data, application/x-www-form-urlencoded). Any other Content-Type, or any custom header at all, moves the request out of "simple" and into "preflighted."
Can I use CORS with cookies?
Yes: send the request with credentials: "include", and have the server respond with Access-Control-Allow-Credentials: true and an exact (non-wildcard) Access-Control-Allow-Origin. Both sides must opt in explicitly.
Is CORS the same thing as CSRF protection?
No, and this is a common source of false security confidence. CORS controls whether JavaScript can read a cross-origin response; it does nothing to stop a cross-origin form submission or an <img>-style GET, which are subject to a different set of rules. CSRF defenses (tokens, SameSite cookies) are a separate mechanism you still need.
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
Cheat sheet
| Task | Header / Code | Notes |
|---|---|---|
| Allow one specific origin | Access-Control-Allow-Origin: https://app.example.com |
Required exact match when credentials are used |
| Allow any origin (no credentials) | Access-Control-Allow-Origin: * |
Never combine with Allow-Credentials
|
| Support multiple origins | Reflect the request's Origin if it's in your allowlist |
Must also send Vary: Origin
|
| Allow cookies cross-origin |
Access-Control-Allow-Credentials: true + credentials: "include" on the client |
No wildcard allowed |
| Declare allowed methods | Access-Control-Allow-Methods: GET, POST, OPTIONS |
Checked against the preflight's requested method |
| Declare allowed headers | Access-Control-Allow-Headers: Content-Type, Authorization |
Must list every custom header the client sends |
| Cache the preflight | Access-Control-Max-Age: 600 |
Seconds; browsers cap the effective maximum |
// The whole pattern, copy-paste ready (Express, hand-rolled — swap for the
// `cors` package once you understand what it's automating for you).
import express from "express";
const app = express();
const ALLOWED_ORIGINS = new Set([
"https://app.example.com",
"https://staging.example.com",
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
res.setHeader("Access-Control-Allow-Credentials", "true");
}
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Max-Age", "600");
if (req.method === "OPTIONS") return res.sendStatus(204);
next();
});
Key takeaways
- CORS is enforced by the browser, checking response headers against the calling page's origin — it does not stop the request from reaching (or executing on) the server.
- A request is either simple (sent directly) or preflighted (an
OPTIONSround trip first); a custom header or a JSONContent-Typeis the most common trigger for the latter. -
Access-Control-Allow-Origin: *and credentials (Access-Control-Allow-Credentials: true) can never be combined — name the exact origin once cookies or tokens are involved. - When reflecting one of several allowed origins, always send
Vary: Origin, or a shared cache will hand your CORS headers to the wrong caller. - CORS is not authentication and not CSRF protection — it's a read-access rule for browser JavaScript, layered on top of, not instead of, your server's own auth checks.
That TypeError: Failed to fetch from the top of this article is now legible: check the Network tab, find the missing or mismatched header, and you'll know within a minute which of the four fixes above it needs. What's the CORS error that took you the longest to actually understand — not just patch with a wildcard? Tell me in the comments.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
- 💼 LinkedIn — linkedin.com/in/parsa-jiravand
- ✉️ Email (work & contract inquiries): bestpractice2026@gmail.com
Top comments (2)
This is a super clear breakdown! I often see folks forget that `Content-Type: application/json
Check my bio for remote job