CORS gets blamed for a lot of things it didn’t do. Half the time the server is fine and the browser is blocking access on purpose. The other half, someone added mode: "no-cors" and made the problem harder to debug.
This guide is the practical version: what the browser actually gives you, what “opaque” really means, and how to make cross-origin responses readable.
The short version
When your frontend calls another origin, the browser decides whether JavaScript can read the response.
You’ll usually hit one of these cases:
- Basic response: same-origin, fully readable
- CORS response: cross-origin, readable because the server allowed it
- Opaque response: cross-origin fetch in
no-corsmode, not readable - Opaque redirect: redirect hidden from JavaScript in certain modes
If you remember one thing, remember this:
no-corsdoes not “disable CORS”. It gives you a crippled opaque response.
What opaque response filtering means
An opaque response is a filtered response object. The browser may have fetched something over the network, but your JavaScript is not allowed to inspect it.
With an opaque response:
response.type === "opaque"response.status === 0response.ok === falseresponse.headersis empty to your codeawait response.text()failsawait response.json()fails
That behavior exists to stop websites from reading cross-origin data unless the target server explicitly opts in with CORS headers.
The classic footgun: mode: "no-cors"
A lot of developers try this when they see a CORS error:
const res = await fetch("https://api.example.com/data", {
mode: "no-cors",
});
console.log(res.type); // "opaque"
console.log(res.status); // 0
Then they try this:
const data = await res.json(); // throws
That’s expected. You got an opaque response, not a readable one.
Use no-cors only when you truly do not need to read the response in JavaScript. In modern app code, that’s rare.
Normal CORS fetch: readable response
For JavaScript to read a cross-origin response, the server must return an Access-Control-Allow-Origin header that matches your origin, or * for public resources without credentials.
Example:
const res = await fetch("https://api.github.com/repos/octocat/Hello-World");
console.log(res.type); // "cors"
console.log(res.status); // 200
const json = await res.json();
console.log(json.full_name);
That works because api.github.com sends:
access-control-allow-origin: *
So the browser allows your script to read the body.
Readable body does not mean readable headers
This is the part people miss.
Even when a CORS response body is readable, not all response headers are readable from JavaScript. By default, the browser exposes only the safelisted response headers.
If the server wants your code to read custom headers like rate limits, pagination links, or ETags, it must send:
Access-Control-Expose-Headers: ...
GitHub’s API does this well. Real response headers from api.github.com include:
access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning
That means frontend code can do this:
const res = await fetch("https://api.github.com/rate_limit");
console.log(res.headers.get("x-ratelimit-limit"));
console.log(res.headers.get("x-ratelimit-remaining"));
console.log(res.headers.get("etag"));
console.log(res.headers.get("link"));
Without Access-Control-Expose-Headers, those would often come back as null even though you can see them in DevTools.
Copy-paste demo: exposed vs hidden headers
async function inspect(url) {
const res = await fetch(url);
console.log("type:", res.type);
console.log("status:", res.status);
const headersToCheck = [
"content-type",
"cache-control",
"etag",
"link",
"x-ratelimit-remaining",
"set-cookie",
];
for (const name of headersToCheck) {
console.log(name, "=>", res.headers.get(name));
}
}
inspect("https://api.github.com/repos/octocat/Hello-World");
What you’ll usually see:
content-type: readablecache-control: often readable if safelisted or exposed depending on contextetag: readable because GitHub exposes itlink: readable because GitHub exposes itx-ratelimit-remaining: readable because GitHub exposes itset-cookie: never readable from frontend JavaScript
Set-Cookie is intentionally blocked from JavaScript access.
Response types you’ll actually see
basic
Same-origin response.
const res = await fetch("/api/me");
console.log(res.type); // "basic"
You can read body and headers normally, subject to normal browser restrictions.
cors
Cross-origin, but allowed by CORS.
const res = await fetch("https://api.github.com/users/octocat");
console.log(res.type); // "cors"
Body is readable. Only exposed headers are readable.
opaque
Cross-origin + no-cors, or another case where the browser intentionally hides details.
const res = await fetch("https://example.com/asset", {
mode: "no-cors",
});
console.log(res.type); // "opaque"
You can’t inspect it.
opaqueredirect
Seen when redirects are hidden, usually with redirect: "manual".
const res = await fetch("https://example.com", {
redirect: "manual",
});
console.log(res.type); // maybe "opaqueredirect"
You won’t get useful redirect details in frontend JavaScript across origins.
Why the browser filters responses
Because without filtering, any site could read data from any other site you’re logged into.
Imagine a malicious page reading:
- your internal admin panel
- your webmail
- your cloud dashboard
- your banking data
CORS is the opt-in mechanism. Opaque filtering is the enforcement mechanism when there is no opt-in.
Server-side headers that control readability
Allow the origin
Public API:
Access-Control-Allow-Origin: *
Single frontend origin:
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
If you dynamically reflect origins, add Vary: Origin or your CDN cache will eventually betray you.
Expose non-safelisted response headers
Access-Control-Expose-Headers: ETag, Link, X-RateLimit-Remaining
Without this, your frontend can’t read those headers even if the request itself succeeds.
Credentials change the rules
If you need cookies or HTTP auth:
fetch("https://api.example.com/me", {
credentials: "include",
});
Then the server must not use *:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
This combination is invalid and browsers will reject it:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Express examples
Public readable API
import express from "express";
const app = express();
app.get("/public", (req, res) => {
res.set("Access-Control-Allow-Origin", "*");
res.set("Access-Control-Expose-Headers", "ETag, Link, X-RateLimit-Remaining");
res.set("ETag", '"abc123"');
res.set("X-RateLimit-Remaining", "42");
res.json({ ok: true });
});
app.listen(3000);
Credentialed API for one frontend
import express from "express";
const app = express();
app.get("/me", (req, res) => {
res.set("Access-Control-Allow-Origin", "https://app.example.com");
res.set("Access-Control-Allow-Credentials", "true");
res.set("Vary", "Origin");
res.json({ user: "alice" });
});
app.listen(3000);
Debugging checklist
When frontend code can’t read a response, I check these in order:
1. Did someone use no-cors?
If yes, remove it unless you explicitly want an unreadable opaque response.
2. What is response.type?
console.log(response.type);
basic: same-origincors: readable body, filtered headersopaque: unreadableopaqueredirect: redirect filtered
3. Does the server send Access-Control-Allow-Origin?
For readable cross-origin fetches, it must.
4. Are the headers you want exposed?
If res.headers.get("etag") returns null, check Access-Control-Expose-Headers.
5. Are credentials involved?
If credentials: "include" is set, wildcard origin won’t work.
6. Is the browser blocking preflight?
That’s a separate CORS failure mode, but it often gets mixed up with opaque responses. Preflight problems stop the readable request before it even happens.
A practical mental model
Think of CORS in two layers:
- Can the browser make the request?
- Can JavaScript read the response?
Opaque response filtering is mostly about layer 2.
The network request may still happen. The browser just hands your code a sealed envelope.
Safe patterns
Good: public API with explicit exposed headers
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: ETag, Link, X-RateLimit-Remaining
Good: authenticated API for one app
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Bad: trying to “fix” CORS with no-cors
fetch("https://api.example.com/private", { mode: "no-cors" });
That just turns a visible failure into an unreadable success-shaped object.
One last gotcha: DevTools can mislead you
You might see the full response in the Network panel and assume your code should be able to read it. Not necessarily.
DevTools shows what the browser received over the network. CORS filtering decides what JavaScript is allowed to access.
That gap is exactly where opaque responses and header exposure rules live.
If you need to read something from frontend code, don’t ask “did the server send it?” Ask:
- did CORS allow this origin?
- did the browser classify the response as readable?
- did the server expose the header I need?
That’s the real checklist.