Few messages in a browser console get misread as often as this one: the request to some other origin has been blocked by CORS policy, and no Access-Control-Allow-Origin header is present. The natural reading is that a thing called CORS stopped your request. The opposite is closer to the truth.
Cross-Origin Resource Sharing is the mechanism that lets a server permit what the browser would otherwise refuse. It is specified in the WHATWG Fetch Standard rather than an RFC, and it exists to open a door that is closed by default. The error means nobody opened it.
The rule CORS relaxes#
Browsers have always let one site load resources from another. Images, scripts, stylesheets, fonts, and form submissions cross origins constantly, and nothing about that is unusual. What the browser will not do by default is let script on page A read the response that came back from origin B.
The reason is ambient authority. Your browser carries cookies, sessions, and saved credentials, and it attaches them automatically to the sites they belong to. If any page could read any response your browser is authorised to receive, a page you opened by accident could quietly read your webmail, your bank's account listing, and your company's internal dashboard, all with your own session doing the work.
So the default is send but do not read. CORS is the opt-in a server uses to say which origins are allowed to read it after all.
What counts as an origin#
An origin is three things: scheme, host, and port. MDN describes the Origin request header as carrying "the origin (scheme, hostname, and port) that caused the request," and it deliberately does not disclose the path. That matters, because a server deciding whether to grant access learns which site is asking, not which page.
Three pairs that people expect to match, and which do not:
http://example.comandhttps://example.com. Different scheme, different origin.example.comandwww.example.com. Different host, different origin, even when one redirects to the other.https://example.comandhttps://example.com:8443. Different port, different origin.
Browsers add the Origin header to every cross-origin request, and also to same-origin requests that are not GET or HEAD. It can also arrive as the literal string null, which happens for sandboxed documents and for schemes such as data: and file:. That is worth knowing mostly so you never answer it: MDN warns that "any origin can create a hostile document with a null origin," so Access-Control-Allow-Origin: null grants access to attackers rather than to a specific caller.
The requests that go straight out#
Some cross-origin requests are sent immediately, with the permission check applied only to the response. These are the ones a plain HTML form could already have made before CORS existed, so requiring a negotiation would protect nothing. To qualify, all of the following must hold:
- The method is
GET,HEAD, orPOST. - The only headers set are CORS-safelisted ones:
Accept,Accept-Language,Content-Language,Content-Type, andRange(single range values only). - If
Content-Typeis set, its value isapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain.
Read that last line again, because it explains most of the preflights people run into. application/json is not on the safelist. A fetch() that posts a JSON body is not a simple request, and never was, no matter how ordinary it feels.
When the response comes back, the browser checks it for an Access-Control-Allow-Origin that covers the requesting origin. If the header is missing or does not match, your code gets an error instead of the response. The request itself already happened, and the server already did whatever it does.
When the browser asks first#
Anything outside that narrow set gets a preflight: a separate OPTIONS request the browser sends on its own, before the real one, to ask whether the real one is acceptable. It carries Origin plus Access-Control-Request-Method, and, when custom headers are involved, Access-Control-Request-Headers.
The triggers are the mirror image of the safelist: any method other than GET, HEAD, or POST; a POST with a content type outside the three above; any header beyond the safelisted ones; and the Authorization header.
The server answers with the permissions it is willing to give, typically Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers. Only if the answer covers what the browser proposed does the real request go out. Nothing you wrote in your application code ever ran during that exchange.
Which produces one of the most common failures in the whole system. Authentication middleware sits in front of the app, sees an OPTIONS request with no session on it, and answers 401 without the CORS headers attached. That reply carries no Access-Control-Allow-Origin, so the exchange dies before the real request is ever sent. The fix is to let OPTIONS through the auth layer rather than to change the front-end. If that status code family is unfamiliar, our guide to HTTP status codes covers what 401 claims that 403 does not.
Credentials change every rule#
Credentials here means cookies, TLS client certificates, and HTTP authentication. By default a cross-origin fetch() sends none of them, and opting in (credentials: 'include') tightens the requirements on both ends.
The server has to set Access-Control-Allow-Credentials: true, and it loses the wildcard everywhere. MDN is explicit that with credentials the server "must not specify the * wildcard" for Access-Control-Allow-Origin, and the same restriction applies to Access-Control-Allow-Headers, Access-Control-Allow-Methods, and Access-Control-Expose-Headers. Each has to name explicit values.
That is not an arbitrary strictness. * means every site on the web, and every site on the web plus your user's cookies is exactly the scenario the same-origin policy exists to prevent.
The usual workaround is to read the incoming Origin and echo it back. That works, and it quietly recreates the wildcard unless you check the value against an allowlist before echoing it. A reflector that accepts anything is a wildcard with extra steps, and it accepts credentials too.
One more constraint sits outside CORS entirely: a cookie only rides along on a cross-site request if it was set with SameSite=None and Secure. Getting the CORS headers right and still seeing an unauthenticated request usually means the cookie, not the header, is the thing missing.
What your JavaScript may read#
A successful CORS request still hides most of the response headers from your code. The Fetch Standard exposes a fixed safelist by default: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, and Pragma. Everything else is invisible until the server names it in Access-Control-Expose-Headers.
This is the bug that makes people doubt their own eyes. A pagination header, a request ID, a rate-limit counter: all present when you run curl, all null when the same response is read from the browser. The header really did arrive. The browser is simply not handing it to your script.
Caching the preflight and the answer#
Preflights double the round trips on every affected call, so browsers cache the result. Access-Control-Max-Age sets the lifetime in seconds, and the numbers are smaller than most people assume. MDN gives the default as 5 seconds when the header is absent, and records two hard ceilings that override anything larger: Firefox caps the value at 86,400 seconds (24 hours), and Chromium from version 76 caps it at 7,200 seconds (2 hours). Sending Access-Control-Max-Age: 604800 does not buy a week.
Shared caches need a second piece. If your server returns an explicit origin rather than *, MDN says the response "should also include a Vary response header with the value Origin," because otherwise a cache in the middle can store the answer given to one site and replay it to another. The symptom is bizarre and intermittent: CORS works, then fails, then works again, depending on who warmed the cache.
CORS is not a lock on your server#
Every part of this is enforced by the browser. A curl command, a Python script, a mobile app, or a server-side proxy is not bound by any of it, and there is nothing for them to bypass: the Access-Control-* headers are instructions to browsers and nothing else reads them.
Two conclusions follow. Restrictive CORS is not access control, so an endpoint that must stay private needs real authentication and authorisation on the server. And permissive CORS is not automatically a hole. A genuinely public API with Access-Control-Allow-Origin: * is publishing what it already publishes.
The exception is an endpoint whose access rules depend on where the caller sits, such as an internal service reachable from a corporate network. Adding * there turns every employee's browser into a usable path to it. CORS also gets confused with Content Security Policy, which answers the opposite question: CORS decides who may read your responses, while CSP decides what your own page is allowed to load and execute. Our CSP analyzer grades that side.
Reading a CORS failure#
Console messages name the specific missing piece. The header is absent. The origin does not match. The method is not in Access-Control-Allow-Methods. A requested header is not in Access-Control-Allow-Headers. Credentials were used with a wildcard. Each points at a different line of server config.
A workable order: look in the network tab for whether an OPTIONS request appeared at all, since its presence or absence tells you which set of rules you are in. If it did, check what came back on that response rather than the real one. Then compare the Access-Control-Allow-Origin value against the Origin string character by character, scheme and port included.
Our HTTP header inspector is useful for the last part. It fetches a URL, follows up to five redirects, grades six security headers from A+ to F, and lists every response header it received in an expandable table. One honest limit: it sends an ordinary GET with no Origin header, so a server that only emits CORS headers in response to an Origin will show none of them there. What it does show is any unconditional Access-Control-* header, plus the Vary and Cache-Control values that decide whether an intermediary can mix two origins' answers together. The Access-Control-* family is not part of the graded set, since a correct value there depends entirely on what the endpoint is for. For the headers that are graded, HTTP security headers goes through each one.
None of this becomes intuitive until the error stops reading as an accusation. The browser is not refusing to send your request. It is refusing to hand you a response that never said you were allowed to have it, and the fix is always on the other end.
See every response header for any URL
Inspect the full header set a URL returns, follow the redirect chain hop by hop, and get the security headers graded from A+ to F in the same view.
Inspect HTTP headers →