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:

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:

What curl reproduces and what only a browser does A six-row comparison table. For sending request headers, receiving raw response headers and following the same network path, curl matches the browser. For running the CORS check, withholding the body and applying SameSite cookie rules, curl does nothing and the reader must supply the judgement. Step in the cross-origin exchange curl browser Send Origin and Access-Control-Request-* -H sets them byte for byte added automatically Receive the raw response headers prints all of them, even on 4xx only six reach JavaScript Traverse the same TLS and proxy path --resolve pins any single hop same route, same TLS Run the CORS check of Fetch 4.10 never — you apply the rule on every response Withhold the body when the check fails prints it regardless script never sees it Apply SameSite rules to cookies sends whatever -b supplies per cookie attributes Above the rule curl settles the question; below it curl only supplies the evidence and you supply the verdict

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

Reproducing a CORS failure with curl curl sends a synthetic preflight and actual request carrying Origin headers; the server returns raw headers; you compare them against the browser's enforcement rules to locate the fault. curl -X OPTIONS + Origin header Server / proxy returns raw headers Allow-Origin matches → browser OK missing / wrong → browser blocks
Figure — curl surfaces the raw response headers; you apply the browser's rules to decide whether a real request would pass.

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:

Bisecting the network path with two curl probes Probe A takes the normal DNS route through the CDN edge and WAF and finds no Access-Control-Allow-Origin. Probe B uses --resolve to enter at the origin Nginx and finds the header present. The difference names the layer that rewrote the response. Probe A — normal DNS resolution no access-control-allow-origin Probe B — --resolve to the origin IP access-control-allow-origin present probe A enters here probe B enters here curl -si -X OPTIONS plus the Origin header CDN edge cache and rules WAF method filter Nginx origin add_header App server CORS middleware --resolve rewrites only DNS, so the same TLS request lands straight on the origin Header present at the origin, absent through the edge: the fault lies in the hops the two probes do not share. Repeat with --resolve pinned to the WAF's own address to decide between the CDN and the WAF.

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.

Topics in This Section