Reproducing CORS Failures with curl
A CORS failure in the browser is opaque: the console reports that a response was blocked, but the response body and most of its headers are hidden from JavaScript by design. curl removes that opacity. Because curl is not a browser, it never applies the Same-Origin Policy and never suppresses a response — it prints the exact raw headers the server returned, letting you reason precisely about what a browser would then decide.
This page is part of Cross-Origin Debugging & Error Diagnosis, which covers the full methodology for isolating CORS failures across the browser, network, and proxy layers. Use curl when you need a deterministic, scriptable reproduction that strips away browser-specific behaviour and shows you the ground truth of the server’s response.
What curl Reproduces — and What It Cannot
The WHATWG Fetch Standard (§4.8, CORS-preflight fetch) defines the exact headers a browser sends during preflight and the exact response headers it checks. curl lets you send those request headers by hand and read the response, but it will never enforce the check for you. The distinction matters:
- curl reproduces the server’s response. Whatever
Access-Control-*headers the server emits,curlshows them verbatim, including on error responses and across redirects. - curl does not reproduce the browser’s judgement. It will happily print a body that a browser would have blocked. You supply the reasoning: “the response lacks a matching
Access-Control-Allow-Origin, therefore a browser would block it.”
This is a feature. It means curl isolates server and proxy behaviour from browser enforcement, which is exactly what you need when a request works in one place and fails in another.
Three flag choices decide how faithful the reproduction is. -i prints the response headers followed by the body, which is fine for a 204 preflight but noisy on a large JSON payload; -D - -o /dev/null writes only the headers to stdout and discards the body, which is what you want in a script. -v additionally shows the request headers curl actually sent, and that is the fastest way to catch a typo in your own -H "Origin: …" before you blame the server. Finally, remember that HTTP/2 and HTTP/3 lowercase every header name on the wire, so access-control-allow-origin in curl’s output is not evidence of a casing bug — grep case-insensitively with grep -i and force --http1.1 only when you are specifically testing a proxy that behaves differently per protocol version.
Laying the two tools side by side makes the division of labour explicit — the top half of this table is what curl settles, the bottom half is what you must still reason about:
Preflight Request Fields Reference
To faithfully reproduce a browser preflight, send the same request headers the browser would. The table below maps each field to the curl flag that sets it and the response header the server must return in reply.
| Browser preflight field | curl flag | Server must reply with |
|---|---|---|
| Request method | -X OPTIONS |
204 or 200 status |
Origin |
-H "Origin: https://app.example.com" |
Access-Control-Allow-Origin matching that origin |
Access-Control-Request-Method |
-H "Access-Control-Request-Method: POST" |
Access-Control-Allow-Methods listing that method |
Access-Control-Request-Headers |
-H "Access-Control-Request-Headers: Authorization, Content-Type" |
Access-Control-Allow-Headers listing those headers |
| Credentials (cookie / token) | -H "Cookie: ..." or -H "Authorization: Bearer ..." |
Access-Control-Allow-Credentials: true + exact origin |
| Show response headers | -i (or -s to silence progress) |
— |
How curl Fits the Debugging Flow
Step-by-Step Reproduction
Step 1 — Reproduce the preflight
Send the synthetic OPTIONS request the browser would send before a non-simple request. This is the canonical command; the dedicated Simulating a Preflight with curl -X OPTIONS page walks through every flag:
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"
A correct preflight replies with HTTP/2 204, an Access-Control-Allow-Origin equal to the origin you sent, an Access-Control-Allow-Methods that includes POST, and an Access-Control-Allow-Headers that lists Authorization and Content-Type.
Step 2 — Reproduce the actual request
Preflight success does not carry over to the real response. Confirm the actual request also carries the origin header — a frequent bug is emitting CORS headers only on the OPTIONS handler:
curl -si -X POST https://api.example.com/v1/orders \
-H "Origin: https://app.example.com" \
-H "Authorization: Bearer test-token" \
-H "Content-Type: application/json" \
-d '{"sku":"A1"}' \
| grep -iE "HTTP/|access-control|vary"
Step 3 — Bisect the network path
If the headers are correct here but wrong through the CDN, the fault is an intermediary. Re-run Step 1 against the origin server directly (resolve the hostname to the origin IP) and compare. This is the core technique behind troubleshooting CORS at the proxy layer:
curl -si -X OPTIONS https://api.example.com/v1/orders \
--resolve api.example.com:443:203.0.113.10 \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
| grep -iE "HTTP/|access-control"
Running the identical request twice with only the entry point changed turns a vague “CORS is broken” into a named layer:
Step 4 — Reproduce the credentialed path
A preflight never carries cookies, so a preflight probe cannot tell you whether the credentialed flow works. Send the credential explicitly on the actual request and check both halves of the credentials contract in one pass:
curl -si https://api.example.com/v1/account \
-H "Origin: https://app.example.com" \
-b "session=REPLACE_WITH_A_TEST_SESSION" \
| grep -iE "^(access-control-allow-(origin|credentials)|vary):"
Two lines must come back: an access-control-allow-origin that is the exact origin you sent, and access-control-allow-credentials: true. A wildcard in the first line is a hard failure no matter what the second line says, and an absent second line means the browser will discard a response curl prints happily. Run the same command with the cookie removed as well — servers that build their CORS headers inside an authenticated code path emit them only when a session is present, which is why the flow breaks for logged-out visitors and nobody can reproduce it.
Edge Cases and Security Boundaries
The null origin. Sandboxed iframes, data: URIs, and some redirects send Origin: null. Reproduce it with -H "Origin: null" and confirm your server does not reflect Access-Control-Allow-Origin: null unless you explicitly support sandboxed contexts — reflecting null grants access to any sandboxed page. See dynamic origin validation patterns for safe matching.
Case sensitivity. Access-Control-Allow-Origin comparison is an exact, case-sensitive string match against the serialized origin. https://App.example.com is a different origin from https://app.example.com; reproduce both to catch a normalization bug.
Credentials. A wildcard Access-Control-Allow-Origin: * is forbidden with credentials. Reproduce the credentialed path (Step 2) and verify the origin is echoed exactly and Access-Control-Allow-Credentials: true is present. The rules are covered in Credential Sharing & Security Boundaries.
Redirects. The Fetch Standard forbids following a redirect during the preflight itself: a 301 or 308 answer to an OPTIONS request is a terminal failure, not a hop. curl will happily follow it if you pass -L, which manufactures a success the browser would never see, so always reproduce preflights without -L. Redirects on the actual request are permitted, but the redirected request is sent with Origin: null when it crosses to a third origin, and the final response must carry its own Access-Control-Allow-Origin. Reproduce the destination URL directly to confirm it does.
Error responses. A 404, 429 or 500 from your API is still a cross-origin response, and it still needs Access-Control-Allow-Origin for the browser to let JavaScript read the status. Many stacks attach CORS headers in a route-level handler that never runs on the error path, which is why a working endpoint suddenly “breaks CORS” the moment it starts returning 500. Reproduce a deliberate error — request a path that does not exist, or force a validation failure — and confirm the headers survive. In Nginx this is exactly what the always parameter on add_header exists for; without it the directive applies only to 2xx and 3xx responses.
Response header casing and duplication. grep -c rather than grep on access-control-allow-origin catches the duplicate-header bug that a plain visual scan misses, because two identical values look like one when the terminal wraps. A count above 1 means two layers are both writing the header and the browser will reject the response as malformed.
Interaction with Caching Layers
When you reproduce a preflight repeatedly through a CDN, a cached 204 may mask a recent policy change. Add a cache-busting query string or inspect the CDN’s cache-status header to confirm you are hitting the origin. A cached preflight served without Vary: Origin is the classic cross-origin cache-poisoning failure; verify it by reproducing the request from two different Origin values and confirming each gets its own matching header. The mechanics are detailed in Handling Vary: Origin Header Correctly.
Framework-Specific Reproduction Notes
The raw headers curl prints are the same for every stack, but the reason they are wrong is stack-specific. These are the mismatches that most often survive a first round of debugging.
Express with the cors package. By default the middleware answers the preflight itself and ends the response. If someone has set preflightContinue: true without registering a downstream OPTIONS handler, the preflight falls through to the router and you will reproduce a 404 where the browser reported a preflight failure. Equally common: app.use(cors()) registered after an authentication middleware, so your curl -X OPTIONS returns 401. Both are visible in the first line of the curl output, which is why Step 1 checks the status before the headers.
Nginx. add_header is not inherited into a location block that declares any add_header of its own — the child block silently discards every parent directive, including the CORS ones. Reproduce against a URL inside the specific location you care about rather than a representative path, and remember that a return 204 inside an if block skips proxy_pass entirely, so the origin application never sees the request you think you are testing.
Django with django-cors-headers. CorsMiddleware must sit above CommonMiddleware; below it, APPEND_SLASH can redirect the preflight and produce the terminal redirect failure described above. Reproduce both /v1/orders and /v1/orders/ and compare.
Spring Boot. @CrossOrigin annotations are applied by the MVC layer, which runs after the Spring Security filter chain. Unless the chain explicitly permits OPTIONS, your curl preflight gets a 401 and the annotation never executes. The reproduction that proves it is a preflight probe with no Authorization header at all.
API gateways. AWS API Gateway with a Lambda proxy integration hands the OPTIONS request to your function unless a mock integration is configured; Cloudflare Workers and similar edge runtimes intercept it before the origin. In both cases the headers you reproduce belong to the gateway, not the application, and the --resolve bisection from Step 3 is the only way to tell them apart.
DevTools + curl Verification Checklist
Common Mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
Running curl without an Origin header |
Server skips CORS logic entirely; you test a code path the browser never hits | Always send -H "Origin: ..." matching the real front-end origin |
Concluding the server is fine because curl printed the body |
curl ignores CORS; a printed body says nothing about browser enforcement |
Judge the Access-Control-Allow-Origin header, not the body |
Testing only OPTIONS, never the actual method |
Misses servers that set CORS headers on preflight but not on the real response | Reproduce both the preflight and the actual request |
Forgetting Access-Control-Request-Headers |
Preflight passes in curl but the browser’s real preflight fails on a missing allowed header |
Send the exact header list the browser would send |
FAQ
If curl succeeds but the browser still fails, is the server correct?
Not necessarily. A plain curl always returns the body because it never enforces CORS. To reproduce what the browser evaluates you must send the Origin header (and, for preflight, Access-Control-Request-Method and Access-Control-Request-Headers) and inspect whether the response echoes a matching Access-Control-Allow-Origin. If curl shows correct headers but the browser fails, the gap is usually credentials mode, a missing header on the actual response, or an intermediary that only mutates browser traffic.
Why does curl return the response body even when CORS should block it?
CORS is enforced by the browser, not by the network or the server. curl is not a browser: it never applies the Same-Origin Policy and never hides a body based on Access-Control-Allow-Origin. That is exactly why it is useful — it shows the raw headers the server sent so you can reason about what a browser would do next.
Do I need to send Access-Control-Request-Headers in a curl preflight?
Only when the actual request will carry non-safelisted headers such as Authorization or Content-Type: application/json. The browser lists those in Access-Control-Request-Headers, and the server must reflect them in Access-Control-Allow-Headers. Send the same value the browser would to reproduce the preflight faithfully.
How do I test a credentialed request with curl?
Send the Origin header plus the credential (-H "Cookie: ..." or -H "Authorization: Bearer ...") and inspect the response for both Access-Control-Allow-Origin (the exact origin, never *) and Access-Control-Allow-Credentials: true. If either is missing or the origin is a wildcard, a real browser would block the credentialed response.
Should I pass -L when reproducing a preflight?
No. The Fetch Standard treats any redirect answer to a preflight as a terminal failure, so following it with -L produces a success the browser will never reproduce. Run preflight probes without -L, and if you see a 301, 307 or 308 in the status line, that redirect is the bug — usually a trailing-slash rule or an HTTP-to-HTTPS upgrade sitting in front of the API. On the actual request -L is safe to use, but check the headers of the final destination rather than the redirect itself.
Why does curl print headers in lowercase when DevTools shows them capitalised?
HTTP/2 and HTTP/3 require lowercase header field names on the wire, and curl prints what it received. DevTools re-cases them for display. The difference is cosmetic and never a cause of a CORS failure, so always match case-insensitively with grep -i. If you genuinely need to compare the HTTP/1.1 representation — for example when a legacy proxy behaves differently per protocol — add --http1.1 and re-run the same probe.