CORS preflight simulator
A page on https://app.example.com calls an API on https://api.example.net. Change the request and the server’s CORS headers, and see what the browser does: whether it sends an OPTIONS preflight first, the exact headers on each request and response, and whether the page gets the response or a network error, with the reason. The rules follow the Fetch Standard and were checked against Chromium. Below it: every CORS header explained, and the common errors with their fixes.
- 1Browser → api.example.net, preflight
OPTIONS /v1/orders/42 HTTP/1.1 Host: api.example.net Origin: https://app.example.com Access-Control-Request-Method: POST Access-Control-Request-Headers: content-type
2api.example.net → browserHTTP/1.1 204 No Content Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: GET, POST Access-Control-Allow-Headers: Content-Type Access-Control-Max-Age: 600
Allowed. The browser may reuse this answer for 600 s.
- 3Browser → api.example.net
POST /v1/orders/42 HTTP/1.1 Host: api.example.net Origin: https://app.example.com Content-Type: application/json
4api.example.net → browserHTTP/1.1 200 OK Access-Control-Allow-Origin: https://app.example.com Content-Type: application/json X-Request-Id: 7f3a9c Cache-Control: no-store
The CORS check passes, so the page can read the response.
fetch() resolves and the page can read the body.
- Content-Type application/json is not one of the three form types
- Headers script can read
- Content-Type, Cache-Control
- Hidden from script
- Access-Control-Allow-Origin, X-Request-Id
- Preflight cached for
- 600 s
How to use the simulator
Start from a scenario, then change one thing at a time. The first row of settings is the request the page makes with fetch(): its method, its Content-Type, whether it sends an Authorization or X-Api-Key header, and whether it includes cookies (credentials: 'include'). The second row is what the API server sends back. The middle panel shows each HTTP message with its headers, and the panel on the right says whether the page gets the response and why.
Things to try:
- From JSON POST, set Content-Type to text/plain: the preflight disappears, because a form could send that.
- From API with a token, add Authorization to Allow-Headers, then DELETE to Allow-Methods.
- Pick Cookies and *, then switch Allow-Origin to app.example.com.
- Make a simple GET fail, and notice that the request still reached the server.
What is CORS?
An origin is the scheme, host and port of a URL: https://app.example.com and https://api.example.net are different origins, and so are http:// and https:// versions of the same host, or two ports. Browsers apply the same-origin policy: a page may send requests to other origins, but its scripts may not read the responses. Without it, any website you visit could read your email or bank pages using the cookies your browser holds for them.
Cross-Origin Resource Sharing (CORS) is the controlled exception. A server that wants its responses to be readable by pages on other origins says so in response headers, starting with Access-Control-Allow-Origin. The browser checks those headers before passing the response to script. For requests that could do something an ordinary web page could not already do, the browser first asks permission with a preflight request.
Simple requests and preflighted requests
HTML forms have always been able to send GET and POST requests to any site, with a few kinds of body. CORS keeps that behaviour unchanged: requests that a form could send go straight to the server, and only the response is checked. These are often called simple requests. A request is simple when:
- its method is GET, HEAD or POST, and
- every header the page sets is on the safelist:
Accept,Accept-Language,Content-Language, andContent-Typewith one ofapplication/x-www-form-urlencoded,multipart/form-dataortext/plain.
Anything else gets a preflight: PUT, PATCH and DELETE; a JSON body (Content-Type: application/json); an Authorization header; any custom header such as X-Api-Key or X-Requested-With. Headers the browser adds itself, such as Origin, User-Agent or cookies, do not count.
The preflight exchange
The preflight is an OPTIONS request to the same URL, without a body or cookies, carrying three headers: Origin, Access-Control-Request-Method with the method of the real request, and Access-Control-Request-Headers with the names of the non-safelisted headers, lower-cased, sorted and separated by commas. The server answers with what it allows:
| Response header | Meaning |
|---|---|
Access-Control-Allow-Origin | The origin allowed to read the response, or * for any origin (not with credentials). |
Access-Control-Allow-Methods | Methods allowed besides GET, HEAD and POST, which are always allowed. * allows all, without credentials. |
Access-Control-Allow-Headers | Request headers allowed. * allows all without credentials, except that the standard does not let it cover Authorization. |
Access-Control-Allow-Credentials | true if the request may include cookies or other credentials. |
Access-Control-Max-Age | Seconds the browser may cache this answer. |
Access-Control-Expose-Headers | On the real response: extra headers script may read. |
The preflight must get a 2xx status and pass the same origin check as the real response, and the method and every requested header must be allowed. If anything fails, the browser never sends the real request. If it passes, the browser sends the real request, and checks Access-Control-Allow-Origin again on that response.
Cookies and credentials
Cross-origin fetch() does not send cookies unless the page asks with credentials: 'include' (or withCredentials = true with XMLHttpRequest). When it does, the rules tighten: the response must name the exact origin in Access-Control-Allow-Origin, include Access-Control-Allow-Credentials: true, and the * wildcard stops working in every CORS header.
A server that allows several origins therefore reads the request’s Origin header, checks it against an allowlist and echoes it back. When the header’s value depends on the request, the server should also send Vary: Origin, so that caches do not serve a response meant for one origin to another. Never echo the Origin without checking it: that is equivalent to allowing every site to read authenticated responses.
Reading response headers
Even when the request succeeds, script on another origin can only read the CORS-safelisted response headers: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified and Pragma. Other headers, such as a request ID, a rate-limit counter or Location, are invisible to response.headers.get() unless the server lists them in Access-Control-Expose-Headers. In the simulator, toggle Expose X-Request-Id and watch it move from hidden to readable.
Preflight caching
A preflight costs a full round trip before every non-simple request, so browsers cache successful preflight answers. Access-Control-Max-Age sets how long, in seconds. Without it the Fetch Standard’s default is 5 seconds, and browsers cap long values: Chromium at 7,200 seconds (two hours) and Firefox at 86,400 (24 hours). The cache entry is specific to the origin, the URL, and the method or header that was allowed, so APIs with many distinct URLs, such as /orders/42 and /orders/43, still pay for a preflight on each URL. Avoiding preflights altogether, by serving the API from the same origin as the page (for example under /api behind the same domain), removes the round trip entirely.
Common CORS errors and how to fix them
Each row starts from the JSON POST scenario and changes one thing. The reason is the one the simulator gives, which matches what the browser console reports in other words.
| Cause | Where it fails | Reason | Fix |
|---|---|---|---|
| No CORS headers at all | Preflight | The response has no Access-Control-Allow-Origin header. | Send Access-Control-Allow-Origin on both the preflight and the real response. |
| Allowed origin is a different site | Preflight | Access-Control-Allow-Origin is https://other.example.com, not https://app.example.com. | Echo the request’s Origin if it is on your allowlist, and add Vary: Origin. |
| Content-Type: application/json not allowed | Preflight | content-type is not listed in Access-Control-Allow-Headers. | Add Content-Type to Access-Control-Allow-Headers. |
| Method not allowed | Preflight | PUT is not listed in Access-Control-Allow-Methods. | List the method in Access-Control-Allow-Methods. |
| Server returns 404 to OPTIONS | Preflight | The server answered the OPTIONS request with 404; a preflight needs a 2xx status. | Answer OPTIONS with 204 or 200 before authentication runs. |
| Cookies with Access-Control-Allow-Origin: * | Preflight | Access-Control-Allow-Origin is * but the request includes credentials, which needs the exact origin. | Return the exact origin when credentials are involved. |
| Cookies without Allow-Credentials | Preflight | The request includes credentials but Access-Control-Allow-Credentials is not true. | Add Access-Control-Allow-Credentials: true. |
| Authorization under Allow-Headers: * | Preflight | authorization is not listed in Access-Control-Allow-Headers (* never covers Authorization). Chromium 141 still allows this. | List Authorization explicitly; the wildcard does not cover it in the standard. |
Two practical points cause many of these. First, CORS headers must be on every response, including errors: if an authentication layer returns 401 or a gateway returns 502 without them, the browser reports a CORS error that hides the real problem. Second, OPTIONS requests carry no credentials, so authentication middleware that runs before the CORS handler rejects every preflight.
What CORS does not do
CORS is a browser feature that decides whether a page may read a response. It is not access control for the server. A simple request reaches the server and is handled before the browser looks at any CORS header; the browser only hides the answer. Requests from curl, a mobile app or another server ignore CORS completely. And a page can still embed images, scripts and stylesheets from other origins, and submit forms to them, without CORS.
So a state-changing endpoint needs its own protection against cross-site request forgery: SameSite cookies, CSRF tokens, or requiring a header that forces a preflight (such as a JSON content type or a custom header) and rejecting requests without it. Authentication and authorisation are still the server’s job.
How the simulator was checked
The rules in the simulator follow the Fetch Standard. To check them, the same model was compared with Chromium 141: a test page on one origin made fetch() calls to a small server on another origin, over 500 random combinations of method, headers, credentials and server CORS headers. For each one we compared whether a preflight was sent, the exact Access-Control-Request-Headers value, whether the real request reached the server, whether fetch() succeeded, and which response headers script could read. All 500 matched.
One difference from the standard turned up. The standard says Access-Control-Allow-Headers: * does not cover Authorization, which must always be listed by name. Chromium 141 accepted it under the wildcard. The simulator follows the standard and notes the difference; to work in every browser, list Authorization explicitly.
CORS in interviews
Frontend and full-stack interviews often ask what CORS is, why a request to an API works in curl but fails in the browser, and why a POST turns into an OPTIONS request. A good answer explains the same-origin policy, the difference between simple and preflighted requests, the headers involved, and the credentials rule about *, and makes clear that CORS protects users’ data in the browser rather than protecting the server.
In system design, CORS comes up when a frontend and an API live on different domains. Serving both from one origin, or terminating CORS once in an API gateway rather than in every service, are common answers.
CORS questions and answers
What is CORS?
Cross-Origin Resource Sharing is how a server tells browsers that pages from other origins may read its responses. By default, the same-origin policy lets a page send requests to other origins but not read the answers. With CORS, the server adds headers such as Access-Control-Allow-Origin to its responses, and the browser checks them before handing the response to the page’s script.
What is a CORS preflight request?
Before sending a cross-origin request that a plain HTML form could not have sent, the browser first sends an OPTIONS request to the same URL, with Access-Control-Request-Method and, if needed, Access-Control-Request-Headers. If the server’s answer allows the method and headers, the browser sends the real request; otherwise it stops and the page gets a network error.
Which requests trigger a preflight?
Any request whose method is not GET, HEAD or POST, and any request with a header outside a short safelist: Accept, Accept-Language, Content-Language, and Content-Type with one of application/x-www-form-urlencoded, multipart/form-data or text/plain. So a POST with Content-Type: application/json, or any request with an Authorization header or a custom header such as X-Api-Key, is preflighted.
Why does my request fail with “No Access-Control-Allow-Origin header is present”?
The response, or the preflight response, did not include Access-Control-Allow-Origin, so the browser will not let the page read it. Common causes are a server that does not add CORS headers at all, an error page or redirect from a proxy or gateway that omits them, or an OPTIONS request that is rejected before the CORS middleware runs.
Can I use Access-Control-Allow-Origin: * with cookies?
No. When a request includes credentials (cookies, HTTP authentication or client certificates, sent with credentials: include or withCredentials), the response must name the exact origin in Access-Control-Allow-Origin and include Access-Control-Allow-Credentials: true. The wildcard * is rejected, as are * in Allow-Methods, Allow-Headers and Expose-Headers.
Does CORS protect my server?
No. CORS is enforced by browsers, and only controls whether a page’s script may read a response. Simple requests are sent to the server before any CORS check, and tools like curl ignore CORS completely. Protect state-changing endpoints with authentication, CSRF tokens or SameSite cookies, not with CORS headers.
How long is a preflight cached?
For as long as Access-Control-Max-Age says, in seconds, up to a browser limit. Without the header the default is 5 seconds. Chromium caps the value at 7,200 seconds (two hours) and Firefox at 86,400 seconds (24 hours). The cache is per origin, URL, and request method and headers.
Why can’t my JavaScript read a response header?
Cross-origin, script can only read the CORS-safelisted response headers (Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified and Pragma) unless the server lists others in Access-Control-Expose-Headers. Without credentials, Access-Control-Expose-Headers: * exposes them all.
Further reading
- WHATWGFetch Standard: the CORS protocolThe definition browsers implement: safelists, preflight and checks.
- MDNCross-Origin Resource Sharing (CORS)A practical guide with every header and examples.
- MDNAccess-Control-Max-AgeDefaults and the limits each browser applies.
- MDNSame-origin policyWhat an origin is and what the policy blocks.
- SemicolonyHow HTTP worksMethods, headers and status codes underneath CORS.