CORS Preflight Failures Explained: Debug OPTIONS Requests with DevTools and cURL
An API URL loads when you enter it directly into a browser, but a fetch() call to it fails from your web app. Or the same endpoint responds correctly in Postman and cURL, while the browser displays a CORS error. If an unexpected OPTIONS request appears in the Network panel, you may be looking at a failed CORS preflight.
The useful way to investigate is to separate three questions: Did the browser send a preflight? Did the server answer it with the required headers? And did the actual request run afterward? This guide walks through that sequence with a repeatable example.
CORS is a browser permission check, not an API login system
Cross-Origin Resource Sharing (CORS) tells the browser whether JavaScript from one origin may read a response from another. An origin consists of a scheme, host, and port. https://app.example.com and https://api.example.com have different origins, even though the domain names look related.
A normal navigation to an API endpoint is not the same operation as a script reading a cross-origin response. Similarly, cURL can retrieve an API response without enforcing browser CORS rules. This is why “it works in cURL” does not close the investigation: it establishes that one HTTP client received a response, not that a browser may expose that response to JavaScript.
CORS also does not replace authentication or authorization. A server may correctly return 401 Unauthorized or 403 Forbidden after CORS succeeds. Fix those application errors separately rather than responding with permissive CORS headers to make the console message disappear.
What triggers a preflight?
A browser sometimes asks permission before making a cross-origin request. It sends an OPTIONS request with the requesting origin, the intended method, and possibly the proposed request-header names.
For example, this cross-origin JavaScript request requires preflight permission because it uses PUT, sets an Authorization header, and sends JSON. The browser sends an OPTIONS request unless a matching permission is already in its preflight cache:
const response = await fetch("https://api.example.com/v1/items/42", {
method: "PUT",
headers: {
"Authorization": "Bearer example-test-token",
"Content-Type": "application/json"
},
body: JSON.stringify({ label: "Updated item" })
});
Here example-test-token is a disposable placeholder, not a real credential. For the broad distinction between JSON and other request bodies, see JSON vs Form Data vs Multipart: Send the Right HTTP Request with cURL.
Not every cross-origin request needs a preflight. Under the Fetch/CORS rules, some combinations of methods, headers, and content types qualify as CORS-safelisted and may go straight to the actual request. Even a request that skips preflight still needs an appropriate CORS response before JavaScript can read it.
Step 1: Catch the OPTIONS request in browser DevTools
Open the page that makes the API call. In Chrome, Edge, Firefox, or Safari, open developer tools, select Network, clear restrictive filters, and make sure recording is enabled. Trigger the action again. Use Preserve log when navigation or redirects might clear the list.
Look for an OPTIONS row immediately before the attempted PUT or POST. Select it and check:
- Request URL: Is it the endpoint you expected, without an accidental trailing slash or redirect?
- Request headers: What are
Origin,Access-Control-Request-Method, andAccess-Control-Request-Headers? - Response status: Did a gateway, proxy, authentication layer, or application return the response?
- Response headers: Are the allow-origin, allow-methods, and allow-headers values present and compatible?
Depending on the browser and failure, the actual API request may never appear. That is important evidence: the browser can stop after the preflight if permission is not granted.
If you need browser-specific shortcuts and a safe way to capture a test, follow How to Check Website Network Requests in Chrome, Firefox, Edge and Safari. Do not share unredacted HAR files, which can include tokens, cookies, and personal information.
Step 2: Read a successful preflight exchange
For the PUT request above, the browser might send something equivalent to:
OPTIONS /v1/items/42 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: authorization,content-type
A service configured to accept this request could respond:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, PUT, OPTIONS
Access-Control-Allow-Headers: authorization, content-type
Vary: Origin
A successful preflight needs a 2xx status; 204 is common for an empty response, but the status alone does not prove success. The browser checks the response headers as well. Header names are case-insensitive; the example is illustrative rather than a required exact serialization.
Access-Control-Allow-Origin should identify an allowed requesting origin. Access-Control-Allow-Methods must cover the proposed method, and Access-Control-Allow-Headers must permit the requested non-safelisted headers. When an application selects the allowed origin dynamically, Vary: Origin helps shared caches avoid reusing a response for the wrong origin.
A successful preflight is not proof that the subsequent PUT succeeds. The actual response also needs the relevant CORS headers, and authentication, validation, business rules, and rate limits still apply.
Step 3: Reproduce the preflight with cURL
Use the same URL, origin, method, and header names recorded by DevTools. For a POSIX shell:
curl --include --request OPTIONS \
--url 'https://api.example.com/v1/items/42' \
--header 'Origin: https://app.example.com' \
--header 'Access-Control-Request-Method: PUT' \
--header 'Access-Control-Request-Headers: authorization,content-type'
This is a diagnostic simulation. cURL does not automatically implement browser preflight logic or decide whether the result is permitted. You must compare its response headers with the browser’s rules and then test in the browser again.
The free cURL command line builder can assemble the request method, URL, and custom headers before you copy the command into your terminal. It prepares a command; it does not send the request itself. Add --include when you need response headers in stdout, or use the builder’s verbose option and examine the output carefully.
If the URL contains query parameters, the URL encoder and decoder can help you inspect encoded values and repeated keys before pasting a clean URL into the command.
Step 4: Distinguish common failure patterns
| What you see | Likely investigation | Better next check |
|---|---|---|
OPTIONS returns 404 or 405 | Route or server does not handle the preflight method | Check gateway and router OPTIONS handling |
OPTIONS returns 401 or 403 | Authentication layer blocks the permission check | Review how preflight requests are handled before auth |
A 301, 302, or other redirect appears | Preflight is redirected by HTTP-to-HTTPS or route normalization | Test the final HTTPS endpoint URL directly |
204 but no Access-Control-Allow-Origin | Server responded, but did not grant cross-origin access | Confirm per-route CORS configuration |
Allowed headers omit authorization | Requested header is not approved | Compare Access-Control-Request-Headers with allowlist |
| Browser reports failure but cURL succeeds | cURL does not enforce browser CORS | Check actual browser request and credentials mode |
| Preflight succeeds, actual request fails | App response, auth, or actual-response CORS is broken | Inspect the following request and its response |
Preflight failures may also originate in a reverse proxy, API gateway, CDN, or serverless routing layer rather than in your application code. A generic “enable CORS” middleware can be ineffective if another layer intercepts the OPTIONS method first.
Step 5: Handle cookies and credentials carefully
A common attempted fix is to send these two response headers together:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
That combination does not authorize a credentialed CORS response in browsers. For a request using credentials: "include", return an explicit permitted origin and Access-Control-Allow-Credentials: true when that access is intended, on both the preflight and actual response. This rule applies even if no cookie is available to send. The earlier bearer-token example does not use credentials: "include"; its explicit Authorization header must still be allowed by name. The preflight itself normally carries no cookies or HTTP authentication credentials under the specification, even if the actual request uses credentials.
Another trap is treating cross-origin and cross-site as synonyms. Cookie SameSite rules and third-party-cookie policies may prevent a cookie from being sent even when CORS is configured correctly. Conversely, adding credentials: "include" to fetch() does not make an unauthorized cookie appear. Compare the browser’s request headers and the server’s session behavior. The Web2Generators guide localStorage vs sessionStorage vs Cookies provides useful background.
Never reflect any arbitrary Origin into Access-Control-Allow-Origin merely to silence errors when private endpoints should be restricted. Define an explicit origin allowlist consistent with your API’s security design.
Step 6: Check caching, redirects, and environment differences
Browsers may cache a successful preflight according to Access-Control-Max-Age, subject to browser caps. This cache is separate from the ordinary HTTP response cache. If changes seem inconsistent, retest with a fresh browser context and compare exact URLs, methods, and headers instead of assuming a single reload clears everything.
CORS can behave differently on localhost, staging, and production because origin includes port and scheme. http://localhost:3000 is not http://localhost:5173, nor is https://app.example.com the same as http://app.example.com.
Check redirects and canonical URLs, especially when an HTTPS migration or trailing-slash redirect has recently been introduced. For general response caching, see Why Your Website Serves Old Files: Debug Cache-Control and ETags with cURL. A response cache and a preflight cache are related debugging concerns, but they are not the same mechanism.
A repeatable CORS troubleshooting checklist
Start from a browser failure, not from a guessed curl command. Capture the Origin, intended method, proposed headers, and exact URL. Inspect the preflight status and allow headers. Reproduce that OPTIONS exchange from the terminal. Confirm the endpoint and gateway are both configured correctly. Then run the actual browser action and inspect its separate response, including cookies and authentication behavior if relevant.
Mozilla’s CORS guide explains the exchanges and common errors. The Fetch Standard defines the preflight algorithm. The goal is not to maximize allowed origins, but to permit the right browser requests without widening access unnecessarily.
For a quick, shareable reproduction using harmless test data, start with the Web2Generators cURL builder and compare its output with your browser’s Network panel.