CORS

Cross-Origin Resource Sharing is the most misunderstood mechanism in web development, mostly because the error message points at the wrong thing. A CORS error is not your server rejecting you — it is your browser refusing to hand a response to your JavaScript. This guide explains what is really happening on the wire, why the request usually succeeded anyway, and how to fix each failure properly. Everything here is reproducible from the API tester, which is not a browser and so is not subject to any of it.

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 originRequest URLSame origin?Why
https://app.example.comhttps://app.example.com/v1/ordersYesPath does not matter
https://app.example.comhttp://app.example.com/v1/ordersNoDifferent scheme
https://example.comhttps://www.example.com/v1Nowww is a different host from the apex
https://app.example.comhttps://api.example.com/v1NoDifferent subdomain, same registrable domain — still cross-origin
http://localhost:3000http://localhost:8080/apiNoDifferent port
http://localhost:3000http://127.0.0.1:3000/apiNoCompared textually; the name and the literal address differ
https://example.comhttps://example.com:443/v1Yes443 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, HEAD or POST.
  • The only headers set by the script are CORS-safelisted ones: Accept, Accept-Language, Content-Language, Content-Type, and Range with a restriction to simple byte ranges.
  • If Content-Type is set, its value is one of application/x-www-form-urlencoded, multipart/form-data or text/plain.
  • The body is not a ReadableStream.
  • No event listener is registered on the request's XMLHttpRequestUpload object.

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

HeaderDirectionWhat it doesTypical value
OriginRequestOrigin the request came from. Set by the browser; a script cannot forge ithttps://app.example.com
Access-Control-Request-MethodPreflight requestThe method the real request will usePATCH
Access-Control-Request-HeadersPreflight requestNon-safelisted headers the real request will set, lowercased and comma-separatedauthorization, content-type
Access-Control-Allow-OriginResponseThe one origin permitted to read this response, or * for any. Never a listhttps://app.example.com
Access-Control-Allow-CredentialsResponsePermits cookies and Authorization. The only valid value is true; omit it otherwisetrue
Access-Control-Allow-MethodsPreflight responseMethods permitted cross-origin for this resourceGET, POST, PUT, PATCH, DELETE
Access-Control-Allow-HeadersPreflight responseRequest headers the script is permitted to set. Must name every non-safelisted oneauthorization, content-type, x-request-id
Access-Control-Expose-HeadersResponseResponse headers JavaScript is allowed to read beyond the safelistx-request-id, ratelimit-remaining
Access-Control-Max-AgePreflight responseSeconds the browser may reuse this preflight result600
VaryResponseNot a CORS header, but essential — tells caches the response depends on OriginOrigin

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-Origin must be one specific origin. The wildcard * is rejected outright, even though it is a broader grant.
  • Access-Control-Allow-Credentials: true must also be present, on the preflight response and on the real response.
  • * is likewise invalid in Access-Control-Allow-Headers, Access-Control-Allow-Methods and Access-Control-Expose-Headers when 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 Origin request 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-Origin and add Access-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 messageReal causeFix
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.

CORSCSRF
ConcernsReading a cross-origin responseCausing a cross-origin write
Enforced byThe browser, using server-sent headersThe server, using tokens; and the browser, using cookie attributes
Defended withCorrect Access-Control-* headersSameSite cookies, anti-CSRF tokens, checking Origin
Failure looks likeA console error; the write may have happened anywayNothing 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.