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 originsSay whether two URLs have the same scheme, host, and port.
- 02Predict preflightsSpot which requests make the browser send an OPTIONS check before the real request.
- 03Read CORS headersExplain why the page can or can't read a response, including requests with cookies.
The browser checks for permission
A web page can load many things from other websites: images, fonts, scripts, and data. Loading is one thing. Reading is another. When your JavaScript wants to read an answer from another website, the browser first checks for permission.
That permission system is called CORS. The letters stand for Cross-Origin Resource Sharing. "Cross-origin" means "from a different website". You'll see the exact meaning of "origin" in the next section.
CORS is not new JavaScript syntax. It is also not an option you add to fetch. It works like this:
- Your page sends a request to a server. A request is a message that asks for something.
- The server sends back a response: the answer.
- The response carries headers. A header is a short line of extra facts, written as a name and a value, such as
Content-Type: application/json. - The browser looks for one header,
Access-Control-Allow-Origin. It says which website may read the response. - If that header is missing, or names a different website, the browser does not give the response to your code.
In an apartment building, the mailroom clerk hands you mail for your own apartment, 4A. You can't open mail for your neighbour in 4B. But your neighbour can leave a note at the desk: "4A may open my mail." With that note, the clerk hands it over. Without the note, the clerk keeps the envelope closed.
- In real life: Mail for your own apartment, 4A
- In JavaScript: A response from your own origin
- In real life: Mail for your neighbour in 4B
- In JavaScript: A response from another origin
- In real life: A note from 4B: "4A may open my mail"
- In JavaScript: The
Access-Control-Allow-Originheader - In real life: The mailroom clerk
- In JavaScript: The browser, which checks the rule
- Is the page asking a different origin?
- Is the request simple, or does it need a preflight? (Both words are explained below.)
- Do the response headers give permission?
- Does the request send cookies or other credentials?
The same-origin policy
INTERACTIVEAn origin is three parts of a URL:
- The scheme:
httporhttps. - The host: the site's name, like
example.com. - The port: a number that picks a door on the server, like
3000. If the URL doesn't show one,httpsuses 443 andhttpuses 80.
The origin of https://example.com:443/products?q=shoes is https://example.com. The port 443 is the default for https, so it is left out. The path /products and the query ?q=shoes are not part of the origin.
The same-origin policy is a safety rule in every browser. JavaScript may read answers from its own origin. To read an answer from a different origin, it needs CORS permission.
Why? Any site you visit can run JavaScript in your browser. Without this rule, a random site could quietly read your bank page or your company's private pages, because your browser is signed in to them.
const app = new URL("http://shop.example:3000/cart");
const api = new URL("https://shop.example:3000/cart");
const store = new URL("http://www.shop.example:3000/cart");
console.log(app.origin === api.origin);
console.log(app.origin === store.origin);
console.log(new URL("http://shop.example:3000").origin === new URL("http://shop.example:5173").origin);new URL(...) reads a URL and splits it into parts. Its .origin property is the origin as text.
- Line 5 compares
httpwithhttps. The schemes differ, so it printsfalse. - Line 6 compares
shop.examplewithwww.shop.example. The hosts differ, so it printsfalse. - Line 7 compares port
3000with port5173. The ports differ, so it printsfalse.
One different part is enough. If only the path were different, the comparison would print true.
function sameOrigin(a, b) { const left = new URL(a); const right = new URL(b); return left.origin === right.origin;}https://shop.examplehttp://shop.exampleDifferent origins: the scheme, the host, or the port is different.
Watch for these cases. Each pair is a different origin:
httpandhttps: different schemes.www.example.comandexample.com: different hosts.localhost:3000andlocalhost:5173: different ports.localhostand127.0.0.1: they reach the same computer, but the host text is different.
A real CORS failure, without leaving this site
REAL BROWSERThis lesson doesn't call any outside website. It can still make a real cross-origin request, safely.
The trick is an iframe: a small page inside this page. The iframe below is sandboxed, which means the browser limits what it may do. It has sandbox="allow-scripts" but not allow-same-origin. So the browser gives it a special origin that matches nothing. This is called an opaque origin, and the browser shows it as null. From inside the iframe, this site is a different 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 a real fetch inside the iframe.
The iframe is sandboxed without allow-same-origin, so its origin is null. Fetching this site from inside it is a cross-origin request, even though the iframe and this page come from the same site.
Each button runs a different fetch inside the iframe:
- Normal fetch: this site's response has no
Access-Control-Allow-Originheader fornull. So the promise fromfetchfails (it rejects) with aTypeError. The message text is different in each browser. - Fetch with no-cors: the option
mode: "no-cors"does not fix CORS. It gives JavaScript an opaque response: a response whose status, headers, and body are all hidden. - Fetch a data: URL: a
data:URL holds its text inside the URL itself. It is not a cross-origin request, so you can read it. Compare it with the other two.
function noCorsResult() {
return { type: "opaque", status: 0, bodyReadable: false };
}
const response = noCorsResult();
console.log(response.type);
console.log(response.status);
console.log(response.bodyReadable);This small model prints opaque, then 0, then false. The status is 0 and the body can't be read. So no-cors is not a way to read data from an API.
const serverReceivedRequest = true;
const browserCanReadResponse = false;
console.log(serverReceivedRequest);
console.log(browserCanReadResponse);Line 4 prints true: the server got the request. Line 5 prints false: the browser did not let the page read the response. Both can be true at the same time.
CORS is a browser rule. A tool like curl, which sends requests from a terminal, doesn't check it. So when you debug, look in two places:
- The Network panel in the browser's DevTools, to see the request and the response headers.
- The server's logs, to see whether the request arrived.
function canBrowserRead(requestOrigin, responseHeaders) {
return responseHeaders["Access-Control-Allow-Origin"] === requestOrigin;
}
console.log(canBrowserRead("https://app.example", { "Access-Control-Allow-Origin": "https://app.example" }));
console.log(canBrowserRead("https://app.example", { "Access-Control-Allow-Origin": "https://other.example" }));canBrowserRead compares the page's origin with the response's Access-Control-Allow-Origin header.
- Line 5: the header names
https://app.example, the same as the page. It printstrue. - Line 6: the header names
https://other.example. It printsfalse.
This only models whether the page may read the response. The server got the request in both cases.
Simple requests and preflight requests
STEP THROUGHSome cross-origin requests are called simple requests. The browser sends them straight away. "Simple" is just an old name. It does not mean "safe". A request is simple when all of these are true:
- The method is
GET,HEAD, orPOST. - Every request header is on a short allowed list. The spec calls these headers safelisted.
AcceptandContent-Typeare on the list;Authorizationand your own headers likeX-Request-Idare not. - If it sets
Content-Type(the kind of data in the body), the value is one of these three:application/x-www-form-urlencoded,multipart/form-data, ortext/plain.
Any other request needs a preflight: a check before the real request. The browser does it for you:
- The browser sends an
OPTIONSrequest. It asks: "May I send this method with these headers?" It names them inAccess-Control-Request-MethodandAccess-Control-Request-Headers. - The server answers with what it allows.
- If the answer is yes, the browser sends the real request. If not, it stops there.
PUT with JSON, the browser first sends an OPTIONS request to ask. Only after the server says yes does it send the real PUT.const request = {
method: "POST",
headers: { "Content-Type": "application/json" },
};
const simpleMethods = ["GET", "HEAD", "POST"];
const simpleTypes = ["text/plain", "multipart/form-data", "application/x-www-form-urlencoded"];
const simple = simpleMethods.includes(request.method) && simpleTypes.includes(request.headers["Content-Type"]);
console.log(simple ? "send directly" : "preflight first");The method is POST, which is on the simple list. But the Content-Type is application/json, which is not one of the three allowed values. So the code prints preflight first.
An Authorization header, or any header you make up, also needs a preflight. That is true even with GET.
function requestKind(method, headers = {}) {
const simpleMethods = ["GET", "HEAD", "POST"];
const contentType = headers["Content-Type"];
const simpleTypes = ["text/plain", "multipart/form-data", "application/x-www-form-urlencoded"];
const headersAreSafe = Object.keys(headers).every((name) => name === "Content-Type" && simpleTypes.includes(contentType));
return simpleMethods.includes(method) && headersAreSafe ? "simple" : "preflight";
}
console.log(requestKind("GET"));
console.log(requestKind("PUT"));
console.log(requestKind("POST", { "Content-Type": "application/json" }));
console.log(requestKind("GET", { Authorization: "Bearer token" }));- Line 9, a plain
GET, printssimple. - Line 10, a
PUT, printspreflight:PUTis not a simple method. - Line 11, a
POSTwith JSON, printspreflight. - Line 12, a
GETwith anAuthorizationheader, printspreflight.
A common mistake is to look only at the method. The headers count too.
You can drop a postcard at the front desk without asking. But before you bring a big crate with special paperwork, you phone first: "I'm bringing a crate like this. Is that OK?" The desk can write down the answer, so you don't have to phone again for a while.
- In real life: A normal postcard
- In JavaScript: A simple
GET, or aPOSTlike a form sends - In real life: A big crate with special paperwork
- In JavaScript: A
PUT, or a request with JSON or your own header - In real life: Phoning the front desk first
- In JavaScript: The preflight
OPTIONSrequest - In real life: The desk writes down "crates are fine" for a week
- In JavaScript:
Access-Control-Max-Age
- GET with no extra headers
- POST with text/plain
- POST with application/json
- PUT
- GET with an Authorization header
- POST with FormData
Sort each cross-origin request: does the browser send it straight away, or check first with a preflight?
Now step through a full preflight. Pick an answer from the server, then step through the browser's checks.
Guess first: will the browser let the page read the response? Then step through the browser's 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);The CORS headers
SIMULATORThe permission is always in the response headers, which the server sends.
The main one is Access-Control-Allow-Origin. Its value can be one origin, such as https://app.example. Or it can be *, which means "any origin". * only works for requests without cookies; the Credentials section explains why.
For a preflight, the server also says which methods and which request headers it allows.
Each row below is one response header. The right column says what it tells the browser.
| Response header | What it tells the browser |
|---|---|
Access-Control-Allow-Origin | Which origin may read this response. * means any origin, but only for requests without cookies. |
Access-Control-Allow-Methods | Which methods are allowed after a preflight, such as GET, POST, PUT. |
Access-Control-Allow-Headers | Which request headers outside the allowed list may be sent after a preflight. |
Access-Control-Allow-Credentials | Whether a request with cookies may be read. The value must be true. |
Access-Control-Max-Age | How many seconds the browser may remember the preflight answer. |
Access-Control-Expose-Headers | Which extra response headers JavaScript may read. |
const responseHeaders = {
"Content-Type": "application/json",
"X-Request-Id": "abc123",
};
function canReadResponseHeader(name, exposedHeaders) {
const safe = ["content-type", "cache-control"].includes(name.toLowerCase());
const exposed = exposedHeaders.toLowerCase().split(",").map((item) => item.trim());
return safe || exposed.includes(name.toLowerCase());
}
console.log(canReadResponseHeader("Content-Type", ""));
console.log(canReadResponseHeader("X-Request-Id", "X-Request-Id"));Even when the page may read a response, it can't read every header on it.
- Line 12:
Content-Typeis on the short list of response headers JavaScript can always read. It printstrue. - Line 13:
X-Request-Idis a custom header. It can be read only because the server named it inAccess-Control-Expose-Headers: X-Request-Id. It printstrue.
This permission is separate from the permission to read the response itself.
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-idNeeds a preflight. Result: allowed. The preflight answer and the real answer both give permission, so the page can read the response.
The simulator is plain JavaScript. It doesn't send any request. Change one setting at a time: the method, the content type, the custom header, cookies, or one of the server's headers. Then read the result.
Cookies and other credentials
Credentials are things that prove who you are to a server. There are three kinds:
- Cookies: small pieces of text a site saves in your browser, often to keep you signed in.
- HTTP authentication: a username and password the browser sends for you.
- Client certificates: digital ID files installed in the browser.
For a cross-origin request, fetch sends credentials only when you ask for them with credentials: "include". The Cookies lesson covers cookies in detail. Here, the CORS rule is what matters.
When a request includes credentials, the server must send two things:
Access-Control-Allow-Credentials: true.- The exact origin of the page in
Access-Control-Allow-Origin.*is refused.
The browser refuses * here because it would let every site read your private, signed-in data.
A server often copies the page's origin into its answer, so the answer is different for each origin. Then it should also send Vary: Origin. A cache is a store of saved responses, kept to save time. Vary: Origin tells caches: "this answer depends on the origin, so don't give one origin's answer to another."
- The request sends credentials only when you ask for them.
- The response says
Access-Control-Allow-Credentials: true. - The response names the exact origin of the page, not
*. - If the server copies the origin into its answer, it also sends
Vary: Origin.
function canReadWithCredentials(origin, credentials, allowOrigin, allowCredentials) {
if (credentials !== "include") return allowOrigin === "*" || allowOrigin === origin;
return allowOrigin === origin && allowCredentials === "true";
}
console.log(canReadWithCredentials("https://app.example", "include", "*", "true"));
console.log(canReadWithCredentials("https://app.example", "include", "https://app.example", "true"));Both calls send credentials ("include"). Both answers say credentials are allowed ("true").
- Line 6: the answer uses
*. It printsfalse. - Line 7: the answer names
https://app.example. It printstrue.
* is refused.So an API that keeps users signed in with cookies, such as a profile API, must name the page's exact origin.
Where you will use this
You'll meet CORS in these places:
- On your own computer, while you build an app.
http://localhost:3000askinghttp://localhost:8080is cross-origin, because the ports differ. - On a live site.
https://app.exampleaskinghttps://api.exampleis cross-origin, because the hosts differ. - When you use another company's service from the browser. That service must give your origin permission.
If the response doesn't have the right CORS headers, adding request headers in JavaScript won't help. It can even cause a preflight. Fix it where the response is made: on the API server, in a CDN rule (a CDN is a network of servers that delivers your files), or in a proxy you control.
A proxy is a server that passes requests on to another server. During development, a dev proxy runs on the same origin as your page. Your page asks the proxy, and the proxy asks the API.
const frontend = "http://localhost:3000";
const api = "http://localhost:8080/orders";
const browserUrl = new URL("/api/orders", frontend);
console.log(browserUrl.origin === frontend);
console.log(new URL(api).origin === frontend);- Line 5 prints
true. The page calls/api/orderson its own origin,localhost:3000. - Line 6 prints
false. Calling the API on port8080directly would be cross-origin.
The proxy then sends the request on to the API. Server-to-server requests don't go through the browser's CORS check. A dev proxy only helps on your own computer. A live site still needs real CORS headers on the server, or the page and API on the same origin.
Common mistakes
- "CORS stops the request." A simple request is still sent, and the server can act on it. CORS stops the page from reading the response.
- "no-cors fixes it." It gives an opaque response. The status, headers, and body are all hidden.
- "The page can add Allow-Origin."
Access-Control-Allow-Originis a response header. Only the server can send it. - "CORS protects against CSRF." CSRF is an attack where another site makes your browser send a request that changes something, like a payment. CORS doesn't stop that, because simple requests are still sent.
- "* works with cookies." A request with credentials needs the exact origin.
These five situations are easy to mix up. Each row says what happens and whether the page can read the response.
| Situation | What happens |
|---|---|
| Same origin | Scheme, host, and port all match. No CORS permission is needed, and the page can read the response. |
| Cross-origin, allowed | The origins differ, but the response headers give permission. The page can read the response. |
| Cross-origin, blocked | The server may have answered, but the browser keeps the response from the page. fetch rejects with a TypeError. |
no-cors | fetch gives an opaque response. The status, headers, and body are all hidden. |
| Server proxy | The page asks its own origin. Your server then asks the other service, with no browser CORS check. |
Practice exercises
5 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 are different, but both URLs have the same scheme, the same host, and port 443.
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");It prints preflight. application/json is not on the allowed list, so the browser checks first.
A page sends a cross-origin GET. The server handles it fine, but fetch fails in the browser. The response has a Content-Type header and no CORS headers. Which response header is missing?
Access-Control-Allow-Origin: https://app.exampleWithout Access-Control-Allow-Origin, the browser has no permission to give the response to the page.
A page on https://app.example sends a request with cookies. The server answers with Access-Control-Allow-Origin: *. What should the Access-Control-Allow-Origin value be instead?
Access-Control-Allow-Origin: https://app.example
Access-Control-Allow-Credentials: true
Vary: OriginCredentials need the exact origin and Access-Control-Allow-Credentials: true. Vary: Origin stops caches from giving one origin's answer to another.
A profile API sends an X-Request-Id header, which the support team uses to find a request. The page may read the response, but response.headers.get("X-Request-Id") returns null. Which response header makes the ID readable?
Access-Control-Expose-Headers: X-Request-IdAccess-Control-Expose-Headers lets page JavaScript read a custom response header, once the response itself is allowed.
Check your understanding
8 QUESTIONSQuestion 1 of 8Which parts make up an origin?
Choose an answer to see the explanation.
Question 2 of 8What 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 8Which cross-origin request needs a preflight?
Choose an answer to see the explanation.
Question 4 of 8A page sends a simple cross-origin POST. The server's answer has no CORS headers. What happens?
Choose an answer to see the explanation.
Question 5 of 8What does
fetch(url, { mode: "no-cors" })give JavaScript for most cross-origin requests?Choose an answer to see the explanation.
Question 6 of 8A request from
https://app.examplesends cookies. Which answer from the server lets the page read the response?Choose an answer to see the explanation.
Question 7 of 8The page may read a response, but JavaScript can't read its
X-Request-Idheader. What is missing?Choose an answer to see the explanation.
Question 8 of 8What does this method check 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
- An origin is the scheme, host, and port. The path is not part of it.
- The same-origin policy stops a page from reading answers from another origin without permission.
- A simple cross-origin request is still sent. CORS decides whether the page may read the response.
- Other requests get a preflight: an
OPTIONScheck before the real request. Access-Control-Expose-Headerslets the page read extra response headers.- With credentials, the server must name the exact origin and send
Access-Control-Allow-Credentials: true. - Fix CORS on the API server, a CDN, or a proxy. Page JavaScript can't give itself permission.
CORS is the browser checking a server's permission headers before it lets your JavaScript read an answer from another origin.
Up next: URL & URLSearchParams.