Simulating a Preflight with curl -X OPTIONS

When a non-simple cross-origin request fails, the browser has already sent a preflight OPTIONS request and rejected the response — but the DevTools console only tells you that it failed, not the raw headers the server returned. Simulating that same preflight with curl -X OPTIONS reproduces the exact exchange on the command line, where every response header is visible and nothing is hidden by the Same-Origin Policy.

This page is part of Reproducing CORS Failures with curl, which covers the broader technique of using curl to isolate server behaviour from browser enforcement.

The Symptom This Resolves

You see a preflight failure in the console and need to know exactly what the server sent back to the OPTIONS request:

Access to fetch at 'https://api.example.com/v1/orders' from origin
'https://app.example.com' has been blocked by CORS policy: Response to
preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin'
header is present on the requested resource.

Root Cause

Per the WHATWG Fetch Standard (§4.8), before sending a non-simple request the browser sends a preflight: an OPTIONS request carrying Origin, Access-Control-Request-Method, and — when the real request has non-safelisted headers — Access-Control-Request-Headers. The server must answer with a 2xx status and matching Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers. If any is missing or mismatched, the browser blocks the request before the real call is ever sent. curl -X OPTIONS sends that identical request so you can read the server’s answer directly.

The preflight is not emitted for every cross-origin call. A GET or POST carrying only CORS-safelisted headers — Accept, Accept-Language, Content-Language, and a Content-Type of application/x-www-form-urlencoded, multipart/form-data, or text/plain — travels as a simple request and produces no OPTIONS at all. Add an Authorization header, a custom X- header, or Content-Type: application/json and the browser inserts the preflight. That is why an accurate reproduction copies the browser’s exact header list rather than a plausible one: the preflight’s shape is derived entirely from the real request, and a single field you leave out changes what the server is being asked.

Each of those fields maps one-to-one onto a curl argument, which is what makes the reproduction exact rather than approximate:

Each preflight line becomes one curl flag Two panels side by side. The left panel lists the request line and the three headers a browser generates for a preflight. The right panel shows the matching curl argument for each, joined by arrows, so the reproduction is a line-for-line translation. What the browser puts on the wire OPTIONS /v1/orders HTTP/2 Origin: https://app.example.com Access-Control-Request-Method: POST Access-Control-Request-Headers: authorization, content-type What you type into curl -X OPTIONS -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: Authorization, Content-Type" Nothing is invented: reproduce the browser's list verbatim or you are testing a different preflight

What the Server Must Send Back

The OPTIONS response is evaluated field by field, and each response header answers a specific request field. Knowing the pairing tells you which server-side list to edit when a comparison fails:

Response header Answers What a wrong value causes
Access-Control-Allow-Origin the Origin request header Absent or non-matching: the preflight fails and no real request is sent
Access-Control-Allow-Methods Access-Control-Request-Method The requested verb is refused even though the route exists
Access-Control-Allow-Headers Access-Control-Request-Headers The named header is refused; matching is case-insensitive but must be literal
Access-Control-Allow-Credentials the client’s credentials: 'include' Absent: cookies are stripped and the response is withheld from script
Access-Control-Max-Age nothing — it is advisory Too high: a stale policy is reused; too low: a preflight before every call
Vary nothing — it scopes shared caches Missing Origin: a cache can hand one origin’s answer to another

Prerequisite State

Step-by-Step

Step 1 — Send the base preflight

curl -si -X OPTIONS https://api.example.com/v1/orders \
  -H "Origin: https://app.example.com"

The -s silences the progress meter; -i includes the response headers in the output. -X OPTIONS sets the method (curl defaults to GET).

Step 2 — Declare the intended method

Add the method the real request will use, exactly as the browser announces it:

curl -si -X OPTIONS https://api.example.com/v1/orders \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST"

Step 3 — Declare the non-safelisted headers

If the real request carries Authorization or Content-Type: application/json, the browser lists them here. Reproduce that exactly:

curl -si -X OPTIONS https://api.example.com/v1/orders \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Authorization, Content-Type"

Step 4 — Filter to the headers that matter

Pipe through grep to see only the status and CORS headers:

curl -si -X OPTIONS https://api.example.com/v1/orders \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Authorization, Content-Type" \
  | grep -iE "HTTP/|access-control|vary"

Those four lines of output are three independent comparisons, and the browser runs all three before it will send the real request:

The three comparisons behind one preflight verdict A three-row matrix. Each row pairs a request field you sent with the response header the server returned and the rule the browser uses to compare them. Origin and method match, but a requested header is absent from the allow list, so the whole preflight fails. What curl sent What came back How the browser compares them Origin: https://app.example.com access-control-allow-origin: https://app.example.com byte-for-byte string equality PASS Access-Control-Request-Method: POST access-control-allow-methods: GET, POST, PUT, DELETE the verb must be a list member PASS Access-Control-Request-Headers: authorization, x-trace access-control-allow-headers: content-type, authorization every requested name must appear FAIL — x-trace is absent One failing comparison ends the preflight — the real POST is never put on the wire curl shows all three comparisons at once; the console error names only the first one that failed

Step 5 — Prove the deny path and the header list

A preflight that passes tells you only half of what you need. Run the command twice more: once with an origin the server must refuse, and once naming a header the server has never been told about.

# Untrusted origin — expect no access-control-allow-origin line at all.
curl -si -X OPTIONS https://api.example.com/v1/orders \
  -H "Origin: https://attacker.example" \
  -H "Access-Control-Request-Method: POST" \
  | grep -iE "HTTP/|access-control|vary"

# Unlisted request header — expect allow-headers to omit it.
curl -si -X OPTIONS https://api.example.com/v1/orders \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: X-Not-Allowed" \
  | grep -iE "HTTP/|access-control"

If the first command echoes https://attacker.example, the server is reflecting the Origin header without validating it, and every website can now drive preflights against your API. If the second command returns X-Not-Allowed inside Access-Control-Allow-Headers, the server is reflecting the requested header list rather than answering from a fixed allowlist — the same flaw one field over. A server that reflects a validated origin must also emit Vary: Origin, or a shared cache will hand one origin’s answer to another; the caching consequences are worked through in Handling Vary: Origin Header Correctly.

Verification

A passing preflight looks like this:

HTTP/2 204
access-control-allow-origin: https://app.example.com
access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS
access-control-allow-headers: Authorization, Content-Type
access-control-max-age: 600
vary: Origin

Confirm in the browser afterwards:

Triage: Reading the Response Top to Bottom

Read the output as a ladder of checks in the order the browser applies them. Each rung that fails points at a different class of server bug, and every rung below it is untested until the failing one is fixed:

Triage ladder for a curl preflight response Five checks stacked vertically, from status code down to the allowed header list. Passing a check moves down to the next one; failing a check branches right to the specific server-side cause, and only clearing all five means the preflight would also pass in a browser. Is the status line a 2xx? 405 from the router, 403 from a WAF, 401 from auth — the OPTIONS never reached your CORS logic no yes Is access-control-allow-origin present? The origin was not matched, or the headers are added only on the 200 path and not on the 204 no yes Does it equal the Origin you sent? A trailing slash, scheme, or port differs — the compare is on the serialized origin, not on the hostname no yes Is your method in allow-methods? The methods list is hard-coded upstream and omits the verb this route actually serves no yes Is every requested header allowed? The allow-headers list is stale — add the exact names; matching ignores case but not spelling no yes This preflight would pass in a browser now re-run it with an untrusted Origin Work down the ladder: the first no is the finding, and everything beneath it is still unverified

There is an important asymmetry in that ladder. A 4xx on the OPTIONS is almost never a CORS bug — it means the request was rejected before any CORS code ran, typically by an authentication middleware registered ahead of the preflight handler, a router with no OPTIONS verb bound to the path, or an edge rule set that drops uncommon methods. The remedy lives in routing order rather than in header values, which is exactly why Proxy Bypass Strategies for CORS Preflight argues for answering OPTIONS at the edge before any of those layers gets a vote.

What curl Cannot Reproduce

curl reproduces the request; it does not reproduce the enforcement. Everything the browser does after reading the response — comparing, caching, blocking — happens nowhere in your terminal, and four differences regularly cause a terminal-passes/browser-fails split.

Redirects. The Fetch Standard fails a CORS-preflight fetch on any 3xx: the browser refuses to follow a redirect during the preflight. curl follows redirects whenever -L is present, so a command with that flag prints the headers of the final URL and hides the redirect completely. Never pass -L when simulating a preflight, and treat a 301, 302, 307, or 308 status line as a failure even when the headers beneath it look perfect. In practice these come from trailing-slash normalisation or an HTTP-to-HTTPS upgrade on the API host.

Fetch metadata and bot rules. curl announces a curl/8.x user agent and sends none of the Sec-Fetch-* metadata a browser attaches. Edge rule sets branch on both. When curl passes and the browser does not, replay the request wearing the browser’s identity:

curl -si -X OPTIONS https://api.example.com/v1/orders \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Sec-Fetch-Mode: cors" \
  -H "Sec-Fetch-Site: cross-site" \
  -A "Mozilla/5.0"

Protocol version. Over HTTP/2 header names are lowercase on the wire, which is why every grep on this page uses -i. If an HTTP/1.1 origin sits behind an HTTP/2 edge, the two hops can disagree about which headers survive; pin the version with --http1.1 and compare the two runs before blaming the application.

The preflight cache. A browser stores a successful preflight for up to Access-Control-Max-Age seconds, keyed by origin, URL, method, and the requested header list. curl keeps no such cache and re-asks every time. A policy change therefore looks live in the terminal while the browser is still honouring the previous answer — clear it with a hard reload in an incognito window rather than assuming the deploy failed. Choosing a value that balances that staleness against round-trip cost is covered in Cache Duration Tuning & Max-Age.

Security Boundary Note

Do not “fix” a failing preflight by making the server reflect Access-Control-Allow-Origin: * or echo whatever Origin arrives without validation. A preflight that passes for every origin means any website can drive requests to your API. Reflect the origin only after matching it against an allowlist, as described in Wildcard vs Dynamic Origin Reflection: When to Use Each. curl makes it easy to verify the deny path: re-run with -H "Origin: https://attacker.example" and confirm no Access-Control-Allow-Origin comes back.

Common Mistakes

Issue Technical impact Mitigation
Omitting -i/-s, so no headers print OPTIONS has no body; the terminal looks empty and you assume failure Always use -si to show response headers
Leaving out Access-Control-Request-Headers curl preflight passes but the browser’s real preflight fails on a disallowed header Send the exact header list the browser sends
Testing a different path than the failing request Server routes OPTIONS differently per path; you validate the wrong route Use the exact URL from the console error
Passing -L out of habit curl follows a 3xx the browser would have rejected outright, so the redirect never appears Drop -L; treat any 3xx status line as a failed preflight
Reaching for -I instead of -si -I requests a HEAD, so the method and body expectation stop matching the preflight Use -si, or -s -D - -o /dev/null when you want the body discarded
Declaring a pass without probing an untrusted origin A server that reflects every Origin passes every preflight you try Re-run Step 5 with Origin: https://attacker.example and expect silence

FAQ

Why does my curl OPTIONS return 200 instead of 204?

Both 200 and 204 are accepted by browsers for a preflight. 204 No Content is preferred because it carries no body, but a 200 with correct Access-Control-Allow-* headers passes just as well. What matters is a 2xx status with matching Allow headers — a 4xx or 5xx on the OPTIONS is the real failure.

curl shows the Allow headers but the browser preflight still fails — why?

Usually your curl command omitted a header the browser sends. If the real request carries Content-Type: application/json, the browser lists Content-Type in Access-Control-Request-Headers and the server must allow it. Re-run curl with the exact Access-Control-Request-Headers the browser sends, read from the DevTools Network panel.

Do I need -X OPTIONS or is there a shorthand?

Use -X OPTIONS explicitly. curl defaults to GET, and the preflight is defined by the OPTIONS method plus the Access-Control-Request-Method header. Pair it with -i or -s so the response headers are displayed, since an OPTIONS response usually has no body.

Can I use curl -I to send the preflight?

Prefer -si. The -I flag means --head: it sets the method to HEAD and tells curl to stop after the response headers. Combining it with -X OPTIONS overrides the method but keeps the no-body expectation, so on a server that returns a body with its preflight response curl can stall or truncate the output. Use curl -si -X OPTIONS, or curl -s -D - -o /dev/null -X OPTIONS if you want the headers printed and any body discarded.

The preflight passes in curl but the browser reports a redirect error — what changed?

Your command almost certainly included -L, so curl silently followed a 3xx on the OPTIONS and printed the headers of the final URL. The Fetch Standard fails a CORS-preflight fetch on any redirect: the browser will not follow one. Drop -L and look at the first status line. A 301, 302, 307, or 308 there is the bug, usually caused by trailing-slash normalisation or an HTTP-to-HTTPS upgrade, and it must be fixed in routing rather than in header values.