On this page 10 sections
- CORS is a browser rule, not a server security wall
- Simple requests and the ones that preflight
- The headers that must match on both sides
- Cookies, credentials and a single origin
- What curl success and a browser failure are telling you
- A debug sequence
- Safe defaults for a public API and a first-party app
- The mistakes that “fix” CORS by turning it off
- Key takeaways
- Frequently asked questions
I keep a CORS Preflight Debugger on this site because the error in the console is often one line, and the missed header is usually on the preflight, the real request, or a credentials mismatch. The method below is the mental model I use, and the sequence I walk when a call fails only in the browser.
CORS is a browser rule, not a server security wall
An origin is the scheme, host and port of a URL. https://app.example.com and https://api.example.com are different origins. So are https://example.com and https://example.com:4443, and HTTP versus HTTPS on the same host.
When JavaScript on a page asks for a different origin, the browser implements the CORS protocol (part of the Fetch standard). The server can opt in by returning the right headers. If it does not, the browser hides the body from the page, even if the HTTP status was 200 and the bytes arrived. That is frustrating, and it is doing what it was designed to do: stop a random website from reading a user’s response from another site, using the user’s cookies or network position, unless that other site agreed.
What CORS does not do:
- It does not stop
curl, a mobile app, a server, or a misbehaving script outside the browser from requesting the URL. Those clients are not the browser’s CORS policy. If the data must stay private, you still need authentication, authorisation, and a network design that does not rely on a browser rule. That is the same kind of split as a privacy manifest: a declaration is not the same as an access control. - It does not replace CSRF defences on cookie-authenticated form posts, though credentialed CORS and cookies interact, as below.
- It does not apply to every tag on a page. An ordinary
<img>,<script src>or<form>navigation follows older rules. The pain you feel is almost always fromfetch,XMLHttpRequest, or a library that uses them.
Same-origin requests are not CORS requests. If the page and the API are on the same origin, you do not need Access-Control-Allow-Origin. Moving the API behind a same-origin path (/api on the same host, proxied) is a valid way to avoid CORS entirely for a first-party app.
Simple requests and the ones that preflight
The browser classifies a cross-origin request as simple or not. The exact list is in the Fetch standard and in MDN’s CORS documentation, and you should confirm it if you are implementing something unusual. In everyday terms, a request may go straight to the server, without a preflight, when all of these are true:
- The method is
GET,HEADorPOST. - The headers are in the “CORS-safelisted” set, such as
Accept,Accept-Language,Content-Language, andContent-Typewhen it has one of a few values. - The
Content-Type, if sent, isapplication/x-www-form-urlencoded,multipart/form-dataortext/plain.
A POST with Content-Type: application/json is not simple. A request with an Authorization header is not simple. A PUT, PATCH or DELETE is not simple. In those cases the browser first sends an OPTIONS preflight to the same URL, with headers such as:
OPTIONS /items HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorizationThe server must answer the OPTIONS request in a way the browser accepts — typically 200 or 204, without a redirect, with:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, PUT, POST, OPTIONS
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 600Access-Control-Max-Age is how long the browser may remember this preflight, in seconds. Keep it modest while you are debugging, so a wrong allow-list does not sit in the client. Only after a successful preflight does the browser send the real PUT.
If you need to see whether a particular method, header set and credential mode will preflight, the CORS Preflight Debugger is built for that question.
The headers that must match on both sides
The important response headers, on both the preflight and the actual request, are:
Access-Control-Allow-Origin. Either the exact requesting origin, such ashttps://app.example.com, or*for a genuinely public, anonymous resource. The value must match the request’sOriginif it is not*. A trailing slash, the wrong scheme, orapp.example.comwithouthttps://will fail.Access-Control-Allow-Methods, on the preflight, listing the method that will be used (and usuallyOPTIONSitself is handled as the preflight).Access-Control-Allow-Headers, on the preflight, listing every non-safelisted request header the page will send. If you forgetAuthorizationorContent-Type, the preflight fails and the real request never goes out.Access-Control-Expose-Headers, on the actual response, if JavaScript needs to read a custom response header such asX-Request-Id. A short list of well-known headers is already visible; the rest are hidden unless you expose them.Access-Control-Allow-Credentials, when cookies or HTTP authentication are included, as the next section describes.
Both the OPTIONS response and the response to the real request need a correct Access-Control-Allow-Origin (and credentials headers, if used). A preflight that succeeds and a real response that omits the header is a very common “it failed after OPTIONS” bug.
If you support more than one front-end origin, allow a known list, and echo the origin only when it is in that list. Reflecting any Origin you are sent is how you give every website permission to read the response.
Cookies, credentials and a single origin
A “credentialed” request is one that includes cookies, client certificates, or HTTP authentication. In fetch, that is credentials: "include". The CORS rules then tighten:
Access-Control-Allow-Originmust be the specific origin. It cannot be*.Access-Control-Allow-Credentials: truemust be present on the relevant responses.- The cookie itself must be eligible to be sent on that cross-site request.
SameSiteand the browser’s third-party cookie rules matter here, and those rules have been tightening. Confirm the current behaviour in the browsers you support, rather than assuming aSet-Cookiefrom last year still arrives on a cross-sitefetch.
If your front end and API are first-party, the least painful design is often: same site (or a clearly related host), a cookie that is allowed on that pairing, and a CORS allow-list of that one front-end origin. If you do not need cookies on the API, do not send them (credentials: "omit" or the default, depending on the caller), and you can use a simpler anonymous policy.
A wildcard origin plus credentials is not a valid combination. Browsers will refuse it. So will any mental model that treats * as “make it work”.
For a native app talking to the same API, CORS is usually not the constraint — the URLSession retries guide is a different kind of failure — but the API should still not treat “no Origin header” as “must be a trusted app”. Authenticate the client properly.
What curl success and a browser failure are telling you
curl does not apply CORS. If curl can GET or POST the URL, you have learned that DNS, TLS, routing, the status code and perhaps authentication with the headers you passed all work. You have not learned whether Access-Control-Allow-Origin is present, whether OPTIONS is handled, or whether a redirect on OPTIONS is breaking the preflight.
A typical mismatch:
curl -sSI https://api.example.com/items
# 200 OK, JSON body, no Access-Control-Allow-OriginThe same URL, from a page on https://app.example.com, fails in the console with a CORS error. The server is fine, from curl’s point of view. The browser is refusing to hand the body to JavaScript.
Use the curl Converter if you want to turn a failing fetch into a curl command, then add -H 'Origin: https://app.example.com' -H 'Access-Control-Request-Method: PUT' -X OPTIONS so you are at least asking the same question the preflight asks. Still inspect the result in the browser; it is the browser that will enforce the rule. The HTTP Header Inspector is useful once you can see the live response headers on both the OPTIONS and the follow-up request.
Redirects deserve a special mention. If OPTIONS or the real request 3xx-redirects to another origin or to HTTP, CORS can fail in ways that look like a missing header. Make the API URL the final URL, on HTTPS, and do not bounce preflights through a trailing-slash redirect if you can avoid it.
A debug sequence
When a request works in curl and fails in the page, I walk this list, in order, in the browser’s Network panel:
- Confirm it is cross-origin. If you did not mean it to be, put the API on the same origin or fix the URL. Look at the exact scheme, host and port.
- Find the failing request, and whether there is an OPTIONS request just before it. If OPTIONS is red, or missing when you expected a preflight, start there.
- Read the OPTIONS status and headers. It should be a success code, not a 404 from a server that only handles
GETandPOST, and not a 301 to a slash variant. CheckAllow-Origin,Allow-MethodsandAllow-Headersagainst theAccess-Control-Request-*headers the browser sent. - Read the real request’s response headers once OPTIONS is clean. The allow-origin (and credentials, if any) must be present there too.
- Compare origins exactly.
https://app.example.comandhttps://www.app.example.comare different. So is a preview host you forgot to add to the allow-list. - Check credentials. If the page uses
credentials: "include", you cannot use*, you must sendAllow-Credentials: true, and the cookie must actually be eligible to be sent. If you do not need the cookie, stop including credentials. - Check for a console message about a disallowed header or method, then add only that method or header to the preflight allow-lists, or stop sending it.
- Only then change the server. A guess that adds
*and'unsafe-everything'in three places will hide the real mismatch and is often invalid anyway.
This is closer to a careful concurrency or networking debug, as in Swift concurrency in practice, than it is to “enable CORS” as a checkbox: you are looking at a handshake, not flipping a feature flag.
Safe defaults for a public API and a first-party app
A public, anonymous API (no cookies, no user-specific data in the response you are willing to share) can send Access-Control-Allow-Origin: * on the resources that are truly public, and does not need Allow-Credentials. Still require whatever authentication you need for private endpoints; do not mark those *.
A first-party web app talking to its own API should allow that one front-end origin (plus perhaps a known preview origin, deliberately), send Allow-Credentials only if you actually use cookies, and list the methods and headers you really send. Prefer a same-origin proxy if CORS is costing more than it is worth.
Do not maintain a single global * plus credentials “for convenience”. Do not echo an arbitrary Origin. Do not leave a wide Allow-Headers: * in front of an old server until you know that server and the browsers you support treat it the way you think (the safe habit is still an explicit list).
If the API is also used with bearer tokens, the Authorization header will preflight, and you should think about token handling in the browser carefully. That sits next to the JWT and JWKS guide; CORS will not make a token in local storage a good idea or a bad one, but it will require you to allow that header if you send it.
The mistakes that “fix” CORS by turning it off
A few non-fixes appear whenever a team is in a hurry:
- Installing a browser extension that disables CORS in the developer’s Chrome. The page still fails for everyone else, and you have trained yourself not to look at the headers.
- Sending
Access-Control-Allow-Origin: *with credentials, or setting both and hoping one will stick. The browser will reject this. - Reflecting any origin, which opts every website into reading the response, and is close to “turning CORS off” in the only place it mattered.
- Handling OPTIONS in a gateway with a generous policy, and forgetting the same headers on
GETandPOST. - Returning those headers from an error page or a 401 inconsistently, so a success works and a failure looks like a CORS bug.
- Allowing
nullas an origin to please afile://test page, and leaving it in production.
CORS headers also interact with caching (Vary: Origin can fragment a CDN cache, as in the HTTP caching headers guide) and should be checked with the rest of your security headers. Fix the handshake for the origins you intend to support. Do not disable the rule that was protecting users of the API.
Key takeaways
- CORS is enforced by the browser so a page cannot read a cross-origin response unless the server opts in.
curlsuccess does not mean the page will be allowed to read the body. - Simple requests go straight through; others send an OPTIONS preflight first. JSON, custom headers and methods other than GET, HEAD and POST usually preflight.
- Allow a specific origin (or
*only for anonymous public resources), and make sure both the preflight and the real response carry the matchingAccess-Control-*headers. - Credentialed requests cannot use
*, needAllow-Credentials: true, and still depend on cookie andSameSiterules that you should confirm in the browsers you support. - Debug in the Network panel: OPTIONS first, then the real request, then credentials, then the allow-lists. Do not “fix” CORS by turning it off in your own browser.
If you want a second pair of eyes on an API that only fails in the browser, tell me about it.
Frequently asked questions
What is a CORS preflight request?
It is an OPTIONS request the browser sends before a cross-origin request that is not “simple”, to ask whether the method and headers are allowed. The server must answer with the appropriate Access-Control-Allow-* headers, usually with a 200 or 204 and no redirect. Only then does the browser send the real request.
Why is my API working in curl but failing in the browser?
Because curl does not enforce CORS. The server may be returning a perfectly good body with no Access-Control-Allow-Origin, or it may not be handling OPTIONS. The browser will hide that body from the page. Inspect the OPTIONS and the real response in DevTools, with the page’s exact Origin.
Do I need Access-Control-Allow-Origin: * ?
Only for a response that is genuinely public and anonymous. First-party apps should allow their specific origin. Credentialed requests cannot use * at all. A wildcard is not a more generous, always-safer option; it is a particular policy with particular limits.
How do cookies work with CORS?
If the page includes credentials, the server must echo the exact origin and send Access-Control-Allow-Credentials: true. The cookie must also be eligible to be sent on that cross-site request, which depends on Secure, SameSite, and the browser’s current third-party cookie behaviour. If you do not need cookies on the call, omit credentials and use a simpler policy.
How do I debug a CORS error in the browser?
Open the Network panel, find the OPTIONS preflight if there is one, and compare Access-Control-Request-Method and Access-Control-Request-Headers with the Allow-* headers on the response. Then check the real request for Allow-Origin and, if needed, Allow-Credentials. Compare origins exactly, avoid redirects on OPTIONS, and only then change the server’s allow-lists.
Written by Lee Johnson. Published 10 November 2026.