CORS & cross-origin requests
Learn how the same-origin policy, preflight requests, CORS response headers, and credentials decide whether browser JavaScript may read a cross-origin response.
- 01Compare originsDecide whether two URLs share a scheme, host, and port.
- 02Predict preflightsSpot which requests need an OPTIONS check before the real request.
- 03Read CORS headersExplain why a response is exposed, blocked, opaque, or unsafe with credentials.
The browser as gatekeeper
A web page can ask for many resources: JSON, images, fonts, scripts, and documents. But when JavaScript tries to read a response from another origin, the browser checks a permission system called CORS: Cross-Origin Resource Sharing.
CORS is not a JavaScript syntax feature and it is not a magic header you add to fetch. It is a conversation between the browser and the server. The browser asks, “May this page read that response?” The server answers with response headers such as Access-Control-Allow-Origin. If the answer is missing or mismatched, the browser refuses to hand the response to your code.
Imagine an apartment building where the mailroom lets residents open mail addressed to their own apartment, but not their neighbor’s. CORS is the neighbor leaving written permission: “Apartment 4A may read my mail.” Without that note, the mailroom clerk keeps the envelope closed.
- In real life: Apartment 4A mail
- In JavaScript: Responses from your own origin
- In real life: Apartment 4B mail
- In JavaScript: Responses from another origin
- In real life: Written permission at the desk
- In JavaScript:
Access-Control-Allow-Origin - In real life: The mailroom clerk
- In JavaScript: The browser enforcing the rule
Where the analogy stops: Real mailrooms stop delivery too. CORS mostly controls whether JavaScript may read the response; simple requests can still reach the server.
When a request fails in the browser, ask four things in order: are the origins different, is the request simple or preflighted, do the response headers allow it, and are credentials involved?
The same-origin policy
INTERACTIVEAn origin is exactly three pieces: scheme, host, and port. The origin of https://example.com:443/products?q=shoes is https://example.com. The path and query string are not part of it.
The same-origin policy is a browser security boundary. JavaScript from one origin can read its own origin freely. To read a different origin, it needs CORS permission. This protects private data, such as an intranet page or a signed-in account page, from being silently read by any random site you visit.
function sameOrigin(a, b) { const left = new URL(a); const right = new URL(b); return left.origin === right.origin;}https://shop.examplehttp://shop.exampleCross-origin: at least one of scheme, host, or effective port differs.
Notice the tricky cases: http and https are different schemes; www.example.com and example.com are different hosts; different ports are different origins; and localhost is not the same host text as 127.0.0.1.
A real CORS failure, without leaving this site
REAL BROWSERThis lesson is a static site, so we will not call an outside API. We can still make a real cross-origin request safely. A sandboxed iframe with sandbox="allow-scripts" and no allow-same-origin gets an opaque origin. Browsers expose that as null. From inside that frame, this page’s own origin is cross-origin.
// Inside a sandboxed iframe with an opaque origin:const target = "https://this-site.example"; async function corsMode() { try { const response = await fetch(target + "/javascript"); return response.type + " " + response.status; } catch (error) { return error.name + ": " + error.message; }} async function noCorsMode() { const response = await fetch(target + "/javascript", { mode: "no-cors" }); return response.type + " " + response.status;}Click a button to run real browser fetch code.
The iframe is sandboxed without allow-same-origin, so its origin is opaque (null). Fetching this site is cross-origin even though everything stays on your machine.
The first button uses normal fetch mode. The static site does not send an Access-Control-Allow-Origin header for the frame’s opaque origin, so the promise rejects with a TypeError. The exact message differs by browser. The second button uses mode: "no-cors". That does not fix CORS; it gives JavaScript an opaque response with unreadable details. The third button fetches a data: URL so you can compare a readable local result.
Simple vs preflighted requests
STEP THROUGHSome cross-origin requests are called simple. The name is historical; it does not mean “safe.” A simple request uses GET, HEAD, or POST, uses only CORS-safelisted request headers, and if it sets Content-Type, the value is one of application/x-www-form-urlencoded, multipart/form-data, or text/plain.
A request outside those limits is preflighted. Before the real request, the browser sends an automatic OPTIONS request with headers like Access-Control-Request-Method and Access-Control-Request-Headers. If the server says yes, the browser sends the real request.
If you deliver a postcard, the front desk may just pass it along. If you bring a locked crate with special paperwork, you call first: “I am bringing a PUT with a custom header. Is that OK?” The answer can be cached for a while.
- In real life: A normal postcard
- In JavaScript: A simple GET or form-like POST
- In real life: A PUT package with special paperwork
- In JavaScript: A non-simple request
- In real life: Calling the front desk first
- In JavaScript: The preflight OPTIONS request
- In real life: Permission kept on file
- In JavaScript:
Access-Control-Max-Age
Where the analogy stops: The analogy hides timing details. Browsers decide exactly which headers are safelisted and when a preflight cache entry may be reused.
- GET without custom headers
- POST with text/plain
- POST with application/json
- PUT
- GET with Authorization header
- POST with FormData
Sort each request by whether a cross-origin browser request needs a preflight.
Predict whether this browser will expose the response. Then step through the browser-style checks.
script
origin: "https://app.example", method: "PUT", headers: { "Content-Type": "application/json", "X-Request-Id": "abc123" }, credentials: "include"}; const response = { "Access-Control-Allow-Origin": "https://app.example", "Access-Control-Allow-Methods": "GET, POST, PUT", "Access-Control-Allow-Headers": "Content-Type, X-Request-Id", "Access-Control-Allow-Credentials": "true", "Vary": "Origin"}; console.log(decideCors(request, response).verdict);CORS headers
SIMULATORCORS permission lives in response headers. The most famous is Access-Control-Allow-Origin. It can be a single origin, such as https://app.example, or * for public non-credentialed reads. For preflighted requests, the server also answers with allowed methods and allowed request headers.
| Header | What it tells the browser |
|---|---|
Access-Control-Allow-Origin | Which origin may read this response, or * for public non-credentialed reads. |
Access-Control-Allow-Methods | Which methods a preflighted request may use, such as GET, POST, PUT. |
Access-Control-Allow-Headers | Which non-safelisted request headers are allowed after preflight. |
Access-Control-Allow-Credentials | Whether credentialed cross-origin reads are allowed. The value must be true. |
Access-Control-Max-Age | How long the browser may cache the preflight answer. |
Access-Control-Expose-Headers | Which non-safelisted response headers JavaScript may read. |
const request = { origin: "https://app.example", url: "https://api.example/orders/7", method: "PUT", headers: { "Content-Type": "application/json", "X-Request-Id": "abc" }, credentials: "include",}; const response = { "Access-Control-Allow-Origin": "https://app.example", "Access-Control-Allow-Methods": "GET, POST, PUT", "Access-Control-Allow-Headers": "Content-Type, X-Request-Id", "Access-Control-Allow-Credentials": "true", "Access-Control-Max-Age": "600", "Vary": "Origin",}; console.log(decideCors(request, response).verdict);OPTIONS /orders/7 | Origin: https://app.example | Access-Control-Request-Method: PUT | Access-Control-Request-Headers: content-type, x-request-idPreflighted request. Verdict: allowed. The preflight and actual response both grant the browser permission to expose the response.
The simulator is intentionally pure JavaScript. It does not send a request. That makes it testable in Node and lets you change one thing at a time: method, content type, custom header, credentials, and each server response header.
Credentials
Credentials are cookies, HTTP authentication, and client certificates. In fetch terms, they appear when you use options such as credentials: "include" for a cross-origin request. Cookie details come in the Cookies lesson; the CORS rule is the important part here.
With credentials, the server must send Access-Control-Allow-Credentials: true. It must also echo the exact origin in Access-Control-Allow-Origin. * is rejected with credentials because it would be too broad. Servers that echo different origins should usually send Vary: Origin so shared caches do not mix responses between callers.
- Request includes credentials only when you ask for them.
- Response says
Access-Control-Allow-Credentials: true. - Response origin is the exact requesting origin, not
*. - Dynamic origin echoing should include
Vary: Origin.
Where you will use this
You will meet CORS when a frontend app talks to an API on another domain or port, when local development runs the frontend and backend on different servers, and when a third-party service must explicitly allow browser clients.
- In development,
http://localhost:3000askinghttp://localhost:8080is cross-origin because the ports differ. - A production app at
https://app.exampleaskinghttps://api.exampleis cross-origin because the hosts differ. - A dev proxy can make browser requests look same-origin locally, but production still needs a real server-side CORS policy or same-origin routing.
If the response lacks the right CORS headers, adding request headers from JavaScript will not fix it. It can even trigger a preflight. Configure the API server, a CDN rule, or a proxy you control.
Common misconceptions
- “CORS stops the request.” Simple requests can still be sent; the browser blocks JavaScript from reading the response.
- “no-cors fixes it.” It returns an opaque, unreadable response.
- “The frontend can add Allow-Origin.” That is a response header from the server.
- “CORS replaces CSRF protection.” It does not; state-changing simple requests can still arrive.
- “Wildcard works with cookies.” Credentialed requests require an exact origin.
| Idea | What it means |
|---|---|
| Same-origin | No CORS permission is needed because scheme, host, and port match. |
| CORS allowed | The request is cross-origin, but response headers grant browser JavaScript access. |
| CORS blocked | The server may have responded, but the browser does not expose the response to JavaScript. |
no-cors | Fetch resolves with an opaque response that hides status, headers, and body. |
| Server proxy | Your app asks its own origin; your server talks to the other service. |
Practice exercises
4 EXERCISESPredict what the starter code prints.
console.log(new URL("https://example.com:443/a").origin === new URL("https://example.com/b").origin);It prints true. The paths differ, but both URLs have the same scheme, host, and effective port.
Read the code and decide which word it prints.
const method = "POST";
const contentType = "application/json";
const simple = method === "GET" || method === "HEAD" ||
(method === "POST" && ["text/plain", "multipart/form-data", "application/x-www-form-urlencoded"].includes(contentType));
console.log(simple ? "simple" : "preflight");The program prints preflight. JSON content is not safelisted, so the browser asks first.
A cross-origin GET succeeds on the server, but fetch rejects in the browser. The response has Content-Type but no CORS headers. Which response header is missing first?
Access-Control-Allow-Origin: https://app.exampleWithout Access-Control-Allow-Origin, the browser has no permission to expose the response.
A request from https://app.example includes cookies. The server currently sends Access-Control-Allow-Origin: *. What should the allow-origin value be instead?
Access-Control-Allow-Origin: https://app.example
Access-Control-Allow-Credentials: true
Vary: OriginCredentials require the exact origin and Allow-Credentials: true; Vary: Origin protects caches when origins are echoed.
Check your understanding
7 QUESTIONSQuestion 1 of 7Which parts make up an origin?
Choose an answer to see the explanation.
Question 2 of 7What does this origin comparison print?
Read the code, then predictconsole.log(new URL("https://example.com:443/a").origin === new URL("https://example.com/b").origin);Choose an answer to see the explanation.
Question 3 of 7Which request normally needs a CORS preflight?
Choose an answer to see the explanation.
Question 4 of 7A simple cross-origin POST is sent to a server with no CORS headers. What happens?
Choose an answer to see the explanation.
Question 5 of 7What does
fetch(url, { mode: "no-cors" })give JavaScript for most cross-origin web requests?Choose an answer to see the explanation.
Question 6 of 7Which CORS response is valid for a credentialed request from
https://app.example?Choose an answer to see the explanation.
Question 7 of 7What does this method classifier print?
Read the code, then predictconst method = "DELETE"; const simple = ["GET", "HEAD", "POST"].includes(method); console.log(simple ? "simple" : "preflight");Choose an answer to see the explanation.
Key takeaways
- Origin means scheme, host, and port. Paths are not included.
- The same-origin policy restricts reading cross-origin responses and other interactions.
- Simple cross-origin requests can be sent; CORS decides whether JavaScript may read the response.
- Non-simple requests use a preflight OPTIONS check before the real request.
- Credentialed CORS requires an exact origin and
Access-Control-Allow-Credentials: true.
CORS is the browser reading a server’s written permission before exposing a cross-origin response to JavaScript.
Up next: URL & URLSearchParams.