The course
Error gallery
Every CORS failure a real project hits is one of a small, closed set of causes. Each exhibit below reproduces one of them live — a genuine cross-origin request, the browser's real console error, the server's own account of what it received, and the smallest server-side change that fixes it.
1 · No Access-Control-Allow-Origin (actual response)
Expected outcome: TypeError; the server received the request
Press Run this demo to fire a real cross-origin request and see this failure happen live — it never runs automatically.
Console message
- Chrome
-
Access to fetch at 'http://localhost:8787/demo/echo?scenario=simple&allow=disallow&id=ccef712b-f56f-4356-8010-da3da28e8acb' from origin 'http://localhost:4321' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. - Firefox
-
[JavaScript Error: "Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:8787/demo/echo?scenario=simple&allow=disallow&id=349fd355-b03a-47dc-acd5-ce90c77d108a. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing). Status code: 200."] - WebKit
-
Origin http://localhost:4321 is not allowed by Access-Control-Allow-Origin. Status code: 200
The minimal fix
Send Access-Control-Allow-Origin, gated against a real allowlist.
// Before: the response never carries the header the browser needs.
app.post("/api/widgets", (req, res) => {
res.json({ ok: true });
});
// After: reflect the caller's Origin only when it's on your real allowlist —
// never a bare wildcard once credentials or a fixed origin are in play.
const ALLOWED_ORIGINS = new Set(["https://example.com"]);
app.post("/api/widgets", (req, res) => {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
}
res.json({ ok: true });
}); 2 · No Access-Control-Allow-Origin (preflight response)
Expected outcome: TypeError; only the OPTIONS preflight arrived
Press Run this demo to fire a real cross-origin request and see this failure happen live — it never runs automatically.
Console message
- Chrome
-
Access to fetch at 'http://localhost:8787/demo/echo?scenario=preflight&variant=missing-acao&id=335d55ec-8eb6-4e50-96f2-9a3b3abeb251' from origin 'http://localhost:4321' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. - Firefox
-
[JavaScript Error: "Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:8787/demo/echo?scenario=preflight&variant=missing-acao&id=e484effe-4c36-4933-939d-eb541d74b4eb. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing). Status code: 204."] - WebKit
-
Origin http://localhost:4321 is not allowed by Access-Control-Allow-Origin. Status code: 204
The minimal fix
Answer the OPTIONS preflight with Access-Control-Allow-Origin, gated against your real allowlist.
// Before: only the actual route sets CORS headers. The OPTIONS preflight the
// browser sent ahead of it gets nothing, so the real request is never authorised
// and never follows — even though the PUT route is ready to handle it.
app.put("/api/widgets", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.json({ ok: true });
});
// After: handle OPTIONS explicitly and grant the origin there too. The preflight
// is a separate response the browser checks on its own; without an
// Access-Control-Allow-Origin on it, the real request is blocked at the gate.
const ALLOWED_ORIGINS = new Set(["https://example.com"]);
app.options("/api/widgets", (req, res) => {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
}
res.status(204).end();
});
app.put("/api/widgets", (req, res) => {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
}
res.json({ ok: true });
}); 3 · Access-Control-Allow-Origin doesn't match
Expected outcome: TypeError; the response carried the wrong origin
Press Run this demo to fire a real cross-origin request and see this failure happen live — it never runs automatically.
Console message
- Chrome
-
Access to fetch at 'http://localhost:8787/demo/echo?scenario=simple&allow=mismatch&id=5c1d6574-9335-4104-9b23-8d8991659d92' from origin 'http://localhost:4321' has been blocked by CORS policy: The 'Access-Control-Allow-Origin' header has a value 'http://127.0.0.1:8787' that is not equal to the supplied origin. Have the server send the header with a valid value. - Firefox
-
[JavaScript Error: "Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:8787/demo/echo?scenario=simple&allow=mismatch&id=98bc483a-b5f0-4f0b-b877-bbf94e17250b. (Reason: CORS header ‘Access-Control-Allow-Origin’ does not match ‘http://127.0.0.1:8787’)."] - WebKit
-
Origin http://localhost:4321 is not allowed by Access-Control-Allow-Origin. Status code: 200
The minimal fix
Reflect the caller's own Origin (allowlist-gated), not a hardcoded value.
// Before: ACAO is one fixed origin, so only that exact origin is ever satisfied.
app.get("/api/widgets", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "https://app.example.com");
res.json({ ok: true });
});
// After: echo the request's own Origin back, but only when it's on your real
// allowlist. A single hardcoded value can never serve more than one origin, and a
// bare "*" is unsafe once credentials are in play.
const ALLOWED_ORIGINS = new Set([
"https://app.example.com",
"https://www.example.com",
]);
app.get("/api/widgets", (req, res) => {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
}
res.json({ ok: true });
}); 4 · Multiple Access-Control-Allow-Origin values
Expected outcome: TypeError; the Fetch spec rejects a multi-value ACAO outright
Press Run this demo to fire a real cross-origin request and see this failure happen live — it never runs automatically.
Console message
- Chrome
-
Access to fetch at 'http://localhost:8787/demo/echo?scenario=simple&allow=double&id=6e25bace-c1ea-408a-985e-d2d51bdc107c' from origin 'http://localhost:4321' has been blocked by CORS policy: The 'Access-Control-Allow-Origin' header contains multiple values 'http://localhost:4321, http://127.0.0.1:8787', but only one is allowed. Have the server send the header with a valid value. - Firefox
-
[JavaScript Error: "Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:8787/demo/echo?scenario=simple&allow=double&id=ba0df66b-6f63-4824-a8c4-34da8a15fbff. (Reason: CORS header ‘Access-Control-Allow-Origin’ does not match ‘http://localhost:4321, http://127.0.0.1:8787’)."] - WebKit
-
Access-Control-Allow-Origin cannot contain more than one origin.
The minimal fix
Send exactly one Access-Control-Allow-Origin — a second value makes the Fetch spec reject the ACAO outright.
// Before: global middleware sets ACAO, then the route appends a second value.
// The wire response now carries two Access-Control-Allow-Origin lines, which the
// Fetch spec rejects regardless of what either says.
app.use((req, res, next) => {
res.append("Access-Control-Allow-Origin", "https://app.example.com");
next();
});
app.get("/api/widgets", (req, res) => {
res.append("Access-Control-Allow-Origin", req.headers.origin ?? "");
res.json({ ok: true });
});
// After: set ACAO exactly once, at a single layer, gated against a real allowlist.
// setHeader() overwrites; append() adds a second line — use setHeader() for ACAO.
const ALLOWED_ORIGINS = new Set(["https://app.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");
}
next();
}); 5 · Wildcard ACAO turns literal under credentials
Expected outcome: TypeError; the wildcard can never satisfy a credentialed request
Press Run this demo to fire a real cross-origin request and see this failure happen live — it never runs automatically.
Console message
- Chrome
-
Access to fetch at 'http://localhost:8787/demo/echo?scenario=simple&allow=wildcard&id=8d65fb85-3d0e-45bf-839d-2644657340a6' from origin 'http://localhost:4321' has been blocked by CORS policy: 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'. - Firefox
-
[JavaScript Error: "Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at ‘http://localhost:8787/demo/echo?scenario=simple&allow=wildcard&id=43be9144-2c65-4ed9-bfd4-2ddc3066aa1c’. (Reason: Credential is not supported if the CORS header ‘Access-Control-Allow-Origin’ is ‘*’)."] - WebKit
-
Cannot use wildcard in Access-Control-Allow-Origin when credentials flag is true.
The minimal fix
Reflect the caller's exact allowlisted origin and add Access-Control-Allow-Credentials — a wildcard can never satisfy a credentialed request.
// Before: a wildcard ACAO. The moment the request carries credentials
// (cookies or Authorization), the browser treats the wildcard as literal —
// it matches no real origin — and blocks the read.
app.get("/api/me", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.json(currentUser(req));
});
// After: echo the caller's exact origin (allowlisted) and opt in to
// credentials explicitly. Both are required together — credentials mode
// forbids the wildcard entirely, so there is no wildcard shortcut here.
const ALLOWED_ORIGINS = new Set(["https://app.example.com"]);
app.get("/api/me", (req, res) => {
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.json(currentUser(req));
}); 6 · Access-Control-Allow-Headers doesn't cover a custom header
Expected outcome: TypeError; only the OPTIONS preflight arrived
Press Run this demo to fire a real cross-origin request and see this failure happen live — it never runs automatically.
Console message
- Chrome
-
Access to fetch at 'http://localhost:8787/demo/echo?scenario=preflight&variant=missing-header&id=44652446-99f9-44a2-8b9b-e3e232d2c490' from origin 'http://localhost:4321' has been blocked by CORS policy: Request header field x-corslabs-demo is not allowed by Access-Control-Allow-Headers in preflight response. - Firefox
-
[JavaScript Error: "Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:8787/demo/echo?scenario=preflight&variant=missing-header&id=31422eeb-6477-476e-9a57-b76de1c043d2. (Reason: header ‘x-corslabs-demo’ is not allowed according to header ‘Access-Control-Allow-Headers’ from CORS preflight response)."] - WebKit
-
Request header field X-Corslabs-Demo is not allowed by Access-Control-Allow-Headers.
The minimal fix
List every non-safelisted request header the client sends in Access-Control-Allow-Headers.
// Before: the preflight authorises the origin and method, but never names the
// custom request header the page sends. A request bearing X-Corslabs-Demo (or
// any other non-safelisted header) is blocked before it leaves, even though the
// route is ready to read it.
app.options("/api/widgets", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT");
res.status(204).end();
});
// After: echo back the headers you actually support in
// Access-Control-Allow-Headers. Only the CORS-safelisted headers (Accept,
// Accept-Language, Content-Language, and the three form Content-Types) ride
// without being named here — every other header must be authorised explicitly.
app.options("/api/widgets", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Corslabs-Demo");
res.status(204).end();
}); 7 · Access-Control-Allow-Methods doesn't cover the method
Expected outcome: TypeError; only the OPTIONS preflight arrived
Press Run this demo to fire a real cross-origin request and see this failure happen live — it never runs automatically.
Console message
- Chrome
-
Access to fetch at 'http://localhost:8787/demo/echo?scenario=preflight&variant=missing-method&id=b12910e3-9049-46db-bf92-195da79dd2d1' from origin 'http://localhost:4321' has been blocked by CORS policy: Method PUT is not allowed by Access-Control-Allow-Methods in preflight response. - Firefox
-
[JavaScript Error: "Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:8787/demo/echo?scenario=preflight&variant=missing-method&id=84eb3e72-11fc-4222-bc6e-51c3023d5904. (Reason: Did not find method in CORS header ‘Access-Control-Allow-Methods’)."] - WebKit
-
Method PUT is not allowed by Access-Control-Allow-Methods.
The minimal fix
Include every non-safelisted method your API uses in Access-Control-Allow-Methods.
// Before: the preflight advertises only GET and POST, but the page sends a PUT.
// PUT is not CORS-safelisted, so without it in Access-Control-Allow-Methods the
// browser never sends the actual request — even though the PUT route exists.
app.options("/api/widgets", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Methods", "GET, POST");
res.status(204).end();
});
// After: name PUT (and every other non-safelisted method your API uses) in
// Access-Control-Allow-Methods. GET, HEAD and POST are safelisted; anything else
// must be authorised here or the real request is never sent.
app.options("/api/widgets", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
res.status(204).end();
}); 8 · Preflight answers with a non-2xx status
Expected outcome: TypeError; the preflight itself was rejected (401, or 404 in the toggle)
Press Run this demo to fire a real cross-origin request and see this failure happen live — it never runs automatically.
Console message
- Chrome
-
Access to fetch at 'http://localhost:8787/demo/echo?scenario=preflight&variant=status-401&id=4a0d8872-b5e8-4b6d-b2df-f5180ba8136d' from origin 'http://localhost:4321' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. - Firefox
-
[JavaScript Error: "Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:8787/demo/echo?scenario=preflight&variant=status-401&id=9edc4680-4eda-4f9f-9823-2a0db25f3cfb. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing). Status code: 401."] - WebKit
-
Preflight response is not successful. Status code: 401
The minimal fix
Answer every OPTIONS preflight with a 2xx — handle it before auth middleware runs.
// Before: auth middleware runs on every request, including the OPTIONS
// preflight. An unauthenticated preflight is rejected with 401, and the browser
// treats any non-2xx preflight as an outright failure — the real request is
// never sent, no matter what the actual route would have returned.
app.use(authMiddleware); // rejects with 401 when there is no session
app.put("/api/widgets", (req, res) => {
res.json({ ok: true });
});
// After: handle OPTIONS preflights before the auth gate. A preflight is the
// browser asking permission on its own — it never carries the cookies or tokens
// your auth check validates — so it must be answered 2xx with the CORS headers,
// independently of authentication. The actual request is still authenticated.
app.options("/api/widgets", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.status(204).end();
});
app.use(authMiddleware); // only the actual request is authenticated
app.put("/api/widgets", (req, res) => {
res.json({ ok: true });
}); 9 · Preflight response is a redirect
Expected outcome: TypeError; the browser never follows a redirected preflight
Press Run this demo to fire a real cross-origin request and see this failure happen live — it never runs automatically.
Console message
- Chrome
-
Access to fetch at 'http://localhost:8787/demo/echo?scenario=preflight&variant=redirect&id=554c2c4f-8ab3-43ee-9bc3-4744d40bbef1' from origin 'http://localhost:4321' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: Redirect is not allowed for a preflight request. - Firefox
-
[JavaScript Error: "Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:8787/demo/echo?scenario=preflight&variant=redirect&id=0194de95-e2f5-422a-8ba3-be21cfa57da6. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing). Status code: 302."] - WebKit
-
Preflight response is not successful. Status code: 302
The minimal fix
Never redirect a preflight — answer OPTIONS directly with a 2xx and the CORS headers.
// Before: a trailing-slash redirect fires on every request, including the
// OPTIONS preflight. The Fetch spec forbids following a redirect during a
// preflight, so the browser blocks the real request outright — the redirect that
// the actual request would have survived kills it at the preflight gate.
app.use((req, res, next) => {
if (req.path !== "/" && !req.path.endsWith("/")) {
return res.redirect(301, req.path + "/");
}
next();
});
app.put("/api/widgets/", (req, res) => {
res.json({ ok: true });
});
// After: bypass the redirect rule for OPTIONS, and answer the preflight directly
// with a 2xx and the CORS headers. Redirects are fine for the actual request;
// they are fatal during a preflight, so the preflight must be handled verbatim.
app.use((req, res, next) => {
if (req.method !== "OPTIONS" && req.path !== "/" && !req.path.endsWith("/")) {
return res.redirect(301, req.path + "/");
}
next();
});
app.options("/api/widgets", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT");
res.status(204).end();
});
app.put("/api/widgets/", (req, res) => {
res.json({ ok: true });
}); 10 · mode: 'no-cors' — an opaque response isn't a success
Expected outcome: fetch() resolves — opaque: status 0, no headers, unreadable body
Press Run this demo to fire a real cross-origin request and see this failure happen live — it never runs automatically.
Console message
- Chrome
-
(confirmed: no console error — this exhibit's fetch() resolves without throwing or logging anything) - Firefox
-
(confirmed: no console error — this exhibit's fetch() resolves without throwing or logging anything) - WebKit
-
(confirmed: no console error — this exhibit's fetch() resolves without throwing or logging anything)
The minimal fix
Drop mode: 'no-cors' — it resolves but the response is opaque. Use a real CORS request and have the server send ACAO.
// Before: mode: 'no-cors' looks like it "works" — fetch() resolves — but the
// response is opaque: status 0, no readable headers, and an unreadable body.
const res = await fetch("https://api.example.com/widgets", { mode: "no-cors" });
const data = await res.json(); // throws — the body is null
// After: make a normal CORS request. The browser then exposes the response only
// when the server opts in with a matching Access-Control-Allow-Origin.
const res = await fetch("https://api.example.com/widgets");
const data = await res.json();
// Server side: gate ACAO against the real origin allowlist.
const ALLOWED_ORIGINS = new Set(["https://app.example.com"]);
app.get("/api/widgets", (req, res) => {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
}
res.json({ ok: true });
}); 11 · The preflight passes, but the actual response forgets ACAO
Expected outcome: TypeError, after both round trips arrived — the two-checkpoint surprise
Press Run this demo to fire a real cross-origin request and see this failure happen live — it never runs automatically.
Console message
- Chrome
-
Access to fetch at 'http://localhost:8787/demo/echo?scenario=preflight&variant=actual-missing-acao&id=a70b0111-60a7-4032-9987-604ca3c9adc3' from origin 'http://localhost:4321' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. - Firefox
-
[JavaScript Error: "Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:8787/demo/echo?scenario=preflight&variant=actual-missing-acao&id=fe5374d1-7f42-40c3-a88e-e8832923b0db. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing). Status code: 200."] - WebKit
-
Origin http://localhost:4321 is not allowed by Access-Control-Allow-Origin. Status code: 200
The minimal fix
Emit Access-Control-Allow-Origin on the actual response too, not only on the preflight — clearing the preflight doesn't exempt the real response.
// Before: the OPTIONS preflight is answered with CORS headers, but the PUT
// handler that follows returns none — so the browser clears the preflight,
// sends the real request, then blocks the read of its response.
app.options("/api/widgets/:id", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "https://app.example.com");
res.setHeader("Access-Control-Allow-Methods", "PUT");
res.sendStatus(204);
});
app.put("/api/widgets/:id", (req, res) => {
res.json(updateWidget(req)); // ← no Access-Control-Allow-Origin here
});
// After: set the CORS headers for every response — preflight and actual alike
// — from one shared place, so no individual handler can forget them.
const ALLOWED_ORIGINS = new Set(["https://app.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");
}
next();
}); 12 · A response header is present but not exposed to JavaScript
Expected outcome: fetch() resolves, body readable, but X-Corslabs-Demo is invisible to JS
Press Run this demo to fire a real cross-origin request and see this failure happen live — it never runs automatically.
Console message
- Chrome
-
(confirmed: no console error — this exhibit's fetch() resolves without throwing or logging anything) - Firefox
-
(confirmed: no console error — this exhibit's fetch() resolves without throwing or logging anything) - WebKit
-
(confirmed: no console error — this exhibit's fetch() resolves without throwing or logging anything)
The minimal fix
List the custom header in Access-Control-Expose-Headers — without it, JavaScript can't read the header even though the response body is fully readable.
// Before: the response carries X-Corslabs-Demo, but it isn't exposed — so
// response.headers.get("X-Corslabs-Demo") is null in the browser, even though
// the fetch resolved and the body read fine.
app.get("/api/report", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "https://app.example.com");
res.setHeader("X-Corslabs-Demo", "1");
res.json(buildReport(req));
});
// After: name the header in Access-Control-Expose-Headers. Only the
// CORS-safelisted response headers are visible by default; every other header
// must be listed here before page JavaScript may read it back.
app.get("/api/report", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "https://app.example.com");
res.setHeader("X-Corslabs-Demo", "1");
res.setHeader("Access-Control-Expose-Headers", "X-Corslabs-Demo");
res.json(buildReport(req));
});