Start a project

Article · HTTP · CORS · APIs · 12 min read

CORS and preflight requests explained

CORS is a browser rule, not a lock on your server. When a page on one origin asks for a URL on another, the browser decides whether JavaScript may read the response, using the Access-Control-* headers the server returns. Some requests are sent at once. Others are preceded by an OPTIONS “preflight” that checks the method and headers. If you test the same URL with curl and it works, that tells you the server responded; it tells you almost nothing about what the browser will allow a page to see.

On this page 10 sections
  1. CORS is a browser rule, not a server security wall
  2. Simple requests and the ones that preflight
  3. The headers that must match on both sides
  4. Cookies, credentials and a single origin
  5. What curl success and a browser failure are telling you
  6. A debug sequence
  7. Safe defaults for a public API and a first-party app
  8. The mistakes that “fix” CORS by turning it off
  9. Key takeaways
  10. 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:

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:

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, authorization

The 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: 600

Access-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:

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:

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-Origin

The 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:

  1. 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.
  2. 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.
  3. Read the OPTIONS status and headers. It should be a success code, not a 404 from a server that only handles GET and POST, and not a 301 to a slash variant. Check Allow-Origin, Allow-Methods and Allow-Headers against the Access-Control-Request-* headers the browser sent.
  4. Read the real request’s response headers once OPTIONS is clean. The allow-origin (and credentials, if any) must be present there too.
  5. Compare origins exactly. https://app.example.com and https://www.app.example.com are different. So is a preview host you forgot to add to the allow-list.
  6. Check credentials. If the page uses credentials: "include", you cannot use *, you must send Allow-Credentials: true, and the cookie must actually be eligible to be sent. If you do not need the cookie, stop including credentials.
  7. 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.
  8. 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:

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

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.

All articles · Swift A–Z · Apps

Questions, or an app in mind?

Let's bring your ideas to life. Whether you're envisioning a game-changing Apple Watch app or a transformative iOS experience, I'm here to make it happen.

Start a project