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:
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
- You know the front-end origin (for example
https://app.example.com) and the target endpoint URL. - You know the method and any custom headers the real request uses (read them from the DevTools Network panel — see Reading a Preflight OPTIONS Request in DevTools).
curlis installed (curl --version).
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:
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:
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.