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.
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"
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:
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 |
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.