What CORS actually is — and what it is not
Start with the thing CORS is built on top of. Browsers enforce the same-origin policy: a script running on one origin may not read responses from another. Without it, a page you visit could quietly fetch your webmail inbox using your logged-in cookies and read every message. The policy is the default, and it is total.
CORS is a relaxation of that policy, not a restriction added on top of it. It gives a server a vocabulary for saying “this particular other origin may read my responses”. Nothing about CORS makes the web more restrictive than it was; every CORS header you can send only ever grants permission. When people describe CORS as something blocking them, they have the polarity backwards — the blocking is the same-origin policy, and CORS is the way out of it.
Four facts follow from this, and getting them straight solves most CORS problems before you touch a config file.
1. The browser enforces it. The server merely advertises.
A server does not “block CORS”. It sends response headers, and the browser decides what to do with them.
Every check happens in the client. This is why the identical request works from
curl, from a backend service, from a mobile app and from a test runner: none of them implement
the same-origin policy, so none of them care what the CORS headers say.
2. The request usually still reaches the server and still runs
This is the single biggest misconception, and it matters operationally. For a request that does not require a preflight, the browser sends it, the server receives it, and the server executes it in full. Rows are inserted. Emails go out. Charges happen. Only when the response comes back does the browser check the headers, find no permission, and refuse to expose the response to the calling script.
So a form submission that failed with a CORS error in the console may have created the record perfectly well. If you are debugging a “failed” request, check the server's access log before you assume nothing happened — and never write a retry loop that fires on a CORS failure, because you may be creating a duplicate every time round. Preflighted requests are the exception: if the preflight is rejected, the real request is never sent at all.
3. A CORS error is not an HTTP error status
There is no CORS status code. The response sitting behind the error is very often a completely healthy 200 OK. The failure lives in the browser's response-handling layer, not in HTTP, so it cannot be represented as a status.
What your code sees is deliberately uninformative. fetch() rejects with an opaque
TypeError — commonly “Failed to fetch” or “NetworkError when attempting to fetch resource” —
carrying no detail about the origin, the header or the missing permission.
XMLHttpRequest fires onerror with status reading 0. The
real explanation is printed to the browser console and to that alone. This vagueness is intentional: if a
page could read why a cross-origin request failed, it could use the difference between failure
modes to map hosts and ports on the user's private network. So the console gets the detail and the script
gets nothing.
4. CORS does not protect your server
It protects the user — specifically, their credentials on other origins. It stops a hostile page from reading responses that the user's own cookies would authorise. It does absolutely nothing to stop an attacker calling your API directly with a script, because that attacker is not running inside a browser and has no same-origin policy to satisfy.
Locking down Access-Control-Allow-Origin is therefore not a security control for your data. It
is a control on which web pages may act on behalf of a signed-in user. Authentication, authorisation, rate
limiting and input validation on the server remain entirely your job — see
API authentication for the layer that actually does that work.
What counts as an origin
An origin is the triple scheme + host + port. All three must match exactly for two URLs to be same-origin. Paths, query strings and fragments are irrelevant; subdomains are not the same as their parent, and a hostname is compared as a string, not resolved to an IP.
| Page origin | Request URL | Same origin? | Why |
|---|---|---|---|
https://app.example.com | https://app.example.com/v1/orders | Yes | Path does not matter |
https://app.example.com | http://app.example.com/v1/orders | No | Different scheme |
https://example.com | https://www.example.com/v1 | No | www is a different host from the apex |
https://app.example.com | https://api.example.com/v1 | No | Different subdomain, same registrable domain — still cross-origin |
http://localhost:3000 | http://localhost:8080/api | No | Different port |
http://localhost:3000 | http://127.0.0.1:3000/api | No | Compared textually; the name and the literal address differ |
https://example.com | https://example.com:443/v1 | Yes | 443 is the default port for https |
The two that catch people daily are the fourth and fifth rows. api.example.com and
app.example.com feel like the same site and are not; a shared registrable domain matters for
cookies, not for origins. And the local development pairing of a front end on :3000 talking to
a back end on :8080 is cross-origin, which is why CORS problems appear on day one of a project
and vanish in production where both are served from one hostname.
Old versions of Internet Explorer ignored the port when comparing origins. That quirk is long dead and not worth designing around.
Simple requests and preflighted requests
Not every cross-origin request asks permission in advance. The specification carves out a class of requests a page could already have made before CORS existed — through an HTML form or an image tag — and lets those go straight out. Everything else must be preflighted.
A request qualifies as simple only if all of the following hold:
- The method is
GET,HEADorPOST. - The only headers set by the script are CORS-safelisted ones:
Accept,Accept-Language,Content-Language,Content-Type, andRangewith a restriction to simple byte ranges. - If
Content-Typeis set, its value is one ofapplication/x-www-form-urlencoded,multipart/form-dataortext/plain. - The body is not a
ReadableStream. - No event listener is registered on the request's
XMLHttpRequestUploadobject.
Now read the third condition again, because it has one consequence that generates more surprise
OPTIONS traffic than everything else combined:
Content-Type: application/json triggers a preflight. JSON is not on the
safelist. It is not on the safelist because an HTML form cannot produce it, so allowing it unannounced would
hand pages a new capability they never had. The moment your front end sends its first JSON
POST, your server starts receiving OPTIONS requests it may not be routed to
handle.
The same is true of an Authorization header, an X-API-Key, a trace ID, a custom
version header — any header your script sets that is not on the safelist. And any method other than the
three listed, so every PUT, PATCH and DELETE is preflighted by
definition. If you are choosing between those two, the PUT vs PATCH
guide covers the semantics; for CORS purposes they behave identically, in that both need naming in
Access-Control-Allow-Methods.
The preflight, annotated
A preflight is an OPTIONS request the browser sends on its own initiative, before the real one.
Your code never sees it and cannot influence it. It asks a single question: may I send the request I am
about to send?
OPTIONS /v1/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com ← who is asking
Access-Control-Request-Method: POST ← the method the real request will use
Access-Control-Request-Headers: authorization, content-type
← the non-safelisted headers it will set
(no body, and no cookies — a preflight is never credentialed)
A response that grants the request looks like this:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com ← must match the Origin, or be *
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE ← must include the requested method
Access-Control-Allow-Headers: authorization, content-type
← must cover every requested header
Access-Control-Max-Age: 600 ← cache this decision for 10 minutes
Vary: Origin ← the response depends on who asked
Only then does the browser send the real POST, which must itself also carry
Access-Control-Allow-Origin in its response — permission granted at preflight does not carry
over to the actual response. Two separate checks, two separate sets of headers.
Two properties of the preflight cause a disproportionate share of bugs. First, it carries
no credentials and no body: no cookies, no Authorization header, nothing to
authenticate with. Second, it must return a 2xx status. Both matter because a great many
servers are configured to require authentication on every route, or to route only the verbs the application
declares. Either produces a 401 or a
405 on the OPTIONS request, and that kills the entire
exchange — the real request is never sent, and the console blames CORS rather than the auth middleware that
actually caused it. The rule is: exempt OPTIONS from authentication, and answer it before your
router gets a chance to reject it. OPTIONS as a method is covered more fully in the
HTTP methods reference.
Access-Control-Max-Age is worth setting. Without it, browsers preflight aggressively and you
double the request count on every write. Browsers cap the value regardless of what you send — Chromium
clamps it to two hours, Firefox to twenty-four — so a large number is not harmful, just ignored above the
cap.
The header reference
| Header | Direction | What it does | Typical value |
|---|---|---|---|
Origin | Request | Origin the request came from. Set by the browser; a script cannot forge it | https://app.example.com |
Access-Control-Request-Method | Preflight request | The method the real request will use | PATCH |
Access-Control-Request-Headers | Preflight request | Non-safelisted headers the real request will set, lowercased and comma-separated | authorization, content-type |
Access-Control-Allow-Origin | Response | The one origin permitted to read this response, or * for any. Never a list | https://app.example.com |
Access-Control-Allow-Credentials | Response | Permits cookies and Authorization. The only valid value is true; omit it otherwise | true |
Access-Control-Allow-Methods | Preflight response | Methods permitted cross-origin for this resource | GET, POST, PUT, PATCH, DELETE |
Access-Control-Allow-Headers | Preflight response | Request headers the script is permitted to set. Must name every non-safelisted one | authorization, content-type, x-request-id |
Access-Control-Expose-Headers | Response | Response headers JavaScript is allowed to read beyond the safelist | x-request-id, ratelimit-remaining |
Access-Control-Max-Age | Preflight response | Seconds the browser may reuse this preflight result | 600 |
Vary | Response | Not a CORS header, but essential — tells caches the response depends on Origin | Origin |
The credentials rule
A request is credentialed when it carries cookies, HTTP authentication or a TLS client certificate — in
fetch() that means credentials: 'include', in
XMLHttpRequest it means withCredentials = true. Credentialed mode changes the
rules, and it changes them strictly:
Access-Control-Allow-Originmust be one specific origin. The wildcard*is rejected outright, even though it is a broader grant.Access-Control-Allow-Credentials: truemust also be present, on the preflight response and on the real response.*is likewise invalid inAccess-Control-Allow-Headers,Access-Control-Allow-MethodsandAccess-Control-Expose-Headerswhen credentials are in play — in credentialed mode the wildcard is treated as the literal header name*, which matches nothing.
The reason for the wildcard ban is that * is a public declaration: “anyone may read this”. A
server can only mean that safely about data it would show to an anonymous caller. The moment cookies attach,
the response is about a specific signed-in user, and “anyone may read this” becomes a description of a
vulnerability. The spec removes the possibility rather than trusting developers to notice.
Since you cannot use a wildcard and cannot send a list, the correct pattern is dynamic:
- Read the
Originrequest header. - Check it against an allowlist of origins you actually trust — exact string comparison, not a substring test and not a loose regular expression.
- If it matches, echo that exact value back in
Access-Control-Allow-Originand addAccess-Control-Allow-Credentials: true. If it does not, send no CORS headers at all. - Always send
Vary: Origin.
The Vary header is not optional decoration. Without it, a shared cache or CDN stores the
response it generated for one allowed origin and serves it, complete with that origin's
Access-Control-Allow-Origin, to a request from a different one. Best case, the second origin
gets a spurious CORS error; worst case, an origin you did not allow receives a header that permits it.
Blindly reflecting whatever arrives in Origin — no allowlist, just copy it across — combined
with Allow-Credentials: true is a genuine vulnerability, and a common one. It means
every origin on the internet is allowed, including a page an attacker controls, and that page can
then read authenticated responses using the victim's cookies. It is the wildcard-with-credentials hole the
spec closed, reopened by hand. Watch equally for sloppy matching: a check for “ends with
example.com” happily accepts evil-example.com, and one for “contains
example.com” accepts example.com.attacker.net.
Why your response header is invisible
By default, cross-origin JavaScript may read only the seven CORS-safelisted response
headers: Cache-Control, Content-Language, Content-Length,
Content-Type, Expires, Last-Modified and Pragma.
Everything else is filtered out of the response object your code receives.
The header is on the wire. You can see it in the network tab. response.headers.get() still
returns null, which is why so many teams conclude the server “isn't sending” a header it is
demonstrably sending. Your X-Request-Id, your pagination header, your
RateLimit-Remaining, the Location of a newly created resource, an
ETag you want for a conditional write — all invisible until the server names them:
Access-Control-Expose-Headers: ETag, Location, X-Request-Id, RateLimit-Remaining
In non-credentialed mode you may use * here to expose everything; with credentials you must
list them. What each of these headers means is covered in the
HTTP headers guide.
Reading the error: causes and fixes
Browser CORS messages are wordier than most, and the wording is genuinely diagnostic — the specific phrasing tells you which check failed.
| Console message | Real cause | Fix |
|---|---|---|
No Access-Control-Allow-Origin header is present on the requested resource |
The server sent no CORS headers at all, or rejected your origin and correctly sent nothing | Add the header on the API. If it is a third-party API you do not control, route the call through your own server |
The value of the Access-Control-Allow-Origin header must not be the wildcard * when the request's credentials mode is include |
Credentialed request against a wildcard grant | Echo the specific validated origin, add Access-Control-Allow-Credentials: true and Vary: Origin — or drop credentials: 'include' if you never needed cookies |
| Response to preflight request doesn't pass access control check | The OPTIONS response was non-2xx or lacked the required headers — most often auth middleware or a router returning 401, 403 or 405 |
Handle OPTIONS before authentication and before routing; return 204 with the allow headers |
Method PATCH is not allowed by Access-Control-Allow-Methods |
The preflight response listed methods but not the one you are using | Add it to the list. Frameworks often default to GET, POST only |
Request header field authorization is not allowed by Access-Control-Allow-Headers |
You set a non-safelisted request header the preflight response does not permit | Name every custom header in the list — authorization and content-type included; they are not implied |
| Redirect is not allowed for a preflight request | The OPTIONS hit a redirect — commonly an http to https upgrade or a trailing-slash rewrite |
Request the final URL directly, with the correct scheme and slash. Preflights may not follow redirects |
| Failed to fetch, with nothing else in the console | Often not CORS at all: DNS failure, connection refused, an invalid TLS certificate, or a blocking extension | Send the same request from a non-browser client. If it works there, it is a browser-layer problem; if it fails there too, it is the network or the server |
When you cannot tell which side is at fault, isolate: send the identical request from the API tester, which runs it outside a browser. If it returns a healthy response there, the server is fine and the fault is in the CORS headers or the preflight. That single comparison eliminates most of the guesswork.
How to actually fix it
Fix the API server — the only real fix
If you control the API, configure it to send the right headers: an allowlist-validated
Access-Control-Allow-Origin, a complete Access-Control-Allow-Methods, an
Access-Control-Allow-Headers naming everything your client sets, an
Access-Control-Expose-Headers for anything the client must read, and
Vary: Origin. Put it in front of authentication so preflights succeed. Every mainstream
framework has a maintained CORS middleware; use it rather than writing header logic by hand, because the
credentialed-mode rules are exactly the sort of thing hand-written code gets subtly wrong.
Make the request same-origin with a dev proxy
In local development, the cleanest answer is usually to remove the cross-origin condition entirely. Every
front-end dev server can proxy a path prefix to your backend: the browser requests
/api/orders on localhost:3000, the dev server forwards it to
localhost:8080, and as far as the browser is concerned nothing crossed an origin. No preflight,
no headers, no problem. The same trick works in production as a reverse-proxy rule, and it has the pleasant
side effect of making cookies simpler too.
Put a backend-for-frontend in between
When the API belongs to somebody else, a small server of your own is the durable answer. Your page calls your BFF same-origin, and the BFF calls the third party server-to-server. Because it is not a browser, CORS never applies to that second hop. You also gain somewhere to keep API keys out of client-side code, to normalise responses and to add caching — see REST vs GraphQL for how an aggregation layer changes the shape of that boundary.
Do not disable CORS in your browser
The extensions that inject Access-Control-Allow-Origin: * into every response are a
development-only hack, and a bad one. They hide a real bug that every one of your users will still hit,
because none of them have the extension. They give you false confidence that the integration works. And
while enabled, they strip the same-origin protection from every site you visit, so any page you
open can read authenticated responses from anywhere you are logged in. If you use one, keep it in a
throwaway browser profile.
no-cors mode is not a fix either
Setting mode: 'no-cors' on a fetch() makes the error disappear, which is not the
same as making the request work. You get an opaque response: status reads 0,
the headers are empty and the body cannot be read at all. It is useful for fire-and-forget beacons and for
caching a resource in a service worker; it is useless for anything where you need the data. If your handler
is receiving an empty response with a status of zero, check whether something set no-cors.
CORS and CSRF are not the same problem
They are constantly conflated because both involve one site talking to another, but they concern opposite directions of the exchange.
| CORS | CSRF | |
|---|---|---|
| Concerns | Reading a cross-origin response | Causing a cross-origin write |
| Enforced by | The browser, using server-sent headers | The server, using tokens; and the browser, using cookie attributes |
| Defended with | Correct Access-Control-* headers | SameSite cookies, anti-CSRF tokens, checking Origin |
| Failure looks like | A console error; the write may have happened anyway | Nothing at all — the attack succeeds silently |
The point that ties the two together is the one from the top of this page. A cross-origin
POST with Content-Type: text/plain is a simple request: no preflight, no
permission asked, straight to your server with the user's cookies attached. The attacker cannot read your
response — CORS sees to that — but they did not want to read it. They wanted the write, and they got it.
CORS has never prevented CSRF. What prevents it is
SameSite=Lax or Strict on session cookies, a CSRF token the attacking page cannot
obtain, and server-side validation of the Origin header on state-changing requests. Token-based
authentication in an Authorization header sidesteps the problem differently: browsers do not
attach it automatically, so there is nothing for a cross-site request to ride on.
Why this site proxies your requests
This is CORS applied to the tool you are reading on. An API tester that ran entirely in the browser would be almost useless, and CORS is exactly why.
Point a browser-only tester at a third-party endpoint and the request goes out, the API answers, and the
browser discards the response — because that API has no reason to send
Access-Control-Allow-Origin naming a testing site it has never heard of. Almost no public API
does. The ones that do usually restrict it to their own dashboard's origin. The tester would show you a
generic network error while the API sat there returning a perfectly good 200 that nothing was
permitted to read.
So the API tester forwards your request through its own server. Your browser makes a same-origin call to us; our server, which is not a browser and therefore has no same-origin policy to enforce, makes the real request to the target API; the response comes back through us to you, headers and status intact. That is the entire reason the REST tester, the GraphQL tester and every other tool here can hit arbitrary public endpoints instead of only the handful that publish permissive CORS headers.
A server that fetches URLs on request needs guarding, so the proxy refuses to connect to private, loopback and link-local addresses — a public API tester should not become a way to reach somebody's internal network. Beyond that, requests pass through unchanged, which is what makes it a useful place to reproduce a call your front end is failing to make. If a request works here and fails in your page, the difference is CORS, and the tables above will tell you which check you are failing.
Frequently asked questions
Does a CORS error mean my request never reached the server?
Usually not. Unless a preflight failed, the request is sent, received and executed normally — the browser simply refuses to let your JavaScript read the response. Check the server's access log before assuming nothing happened.
What status code is a CORS error?
There is none. The underlying response is often a healthy 200.
fetch() rejects with an opaque TypeError, XMLHttpRequest reports
status 0, and only the console gets the real reason — deliberately, so pages cannot probe
private networks.
Why does Content-Type: application/json trigger a preflight?
Only application/x-www-form-urlencoded, multipart/form-data and
text/plain are safelisted — the types an HTML form could already send. JSON is not, so the
browser must ask permission with an OPTIONS request first.
Why is the wildcard rejected when I send credentials?
Because * means “anyone may read this”, which cannot be true of a response about a signed-in
user. Validate the Origin against an allowlist, echo it back, add
Access-Control-Allow-Credentials: true and Vary: Origin.
Does CORS protect my server from attackers?
No. It protects the user's browser session on other origins. Anyone with a script and no browser ignores it entirely, so server-side authentication and authorisation remain your responsibility.
Why can my JavaScript not read a response header that is clearly there?
Cross-origin, only seven safelisted response headers are exposed. Anything else — ETag,
X-Request-Id, rate-limit headers — must be named in
Access-Control-Expose-Headers.