← Recipes

阅读中文版本

Debug a Failing CORS Preflight with curl

Reproduce an OPTIONS preflight by hand with curl, read the Access-Control-* response headers, and fix the exact header the browser is complaining about.

~20 minapicorsdebugging

Task

Reproduce a failing CORS preflight outside the browser and identify the exact missing or mismatched Access-Control header.

When to use this

The browser console shows a CORS error that names no header clearly. The endpoint works from Postman, so the team guesses between origin, method, and header misconfiguration.

Prerequisites

  • curl available on PATH
  • The exact failing request copied from the browser Network tab (Copy as cURL)
  • Access to server or proxy configuration

Steps

1. Replay the failing request as-is to confirm the error is reproducible.

curl -s -o /dev/null -w "%{http_code}\n" "https://api.example.com/v1/search" -H "Origin: https://app.example.com"

Expected output: A plain GET returns 200, which proves basic connectivity and narrows the failure to the preflight.

2. Send the OPTIONS preflight exactly as the browser would.

curl -s -D - -o /dev/null -X OPTIONS "https://api.example.com/v1/search" -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: content-type,x-api-key"

Expected output: The response headers print; a working preflight returns 204 with Access-Control-Allow-Origin and the requested method and headers echoed back.

3. Compare requested headers against the allowed list returned.

curl -s -D - -o /dev/null -X OPTIONS "https://api.example.com/v1/search" -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: content-type" | grep -i access-control

Expected output: If Access-Control-Allow-Headers omits content-type, the browser error will name that header; add it to the server allow list.

4. Apply the fix and verify with the strictest possible origin.

curl -s -D - -o /dev/null -X OPTIONS "https://api.example.com/v1/search" -H "Origin: https://unknown.example.org" -H "Access-Control-Request-Method: POST"

Expected output: The server must not reflect an unknown origin; a rejected preflight here is correct behavior, proving the allow list is enforced.

Troubleshooting

Problem

Preflight returns 200 but the browser still blocks

Solution

The response lacks Access-Control-Allow-Headers for custom headers like x-api-key; the browser requires every requested header to be listed.

Problem

Credentialed requests fail

Solution

Access-Control-Allow-Origin cannot be the wildcard when credentials are included; echo the exact origin and set Access-Control-Allow-Credentials: true.

Problem

Works on one route, fails on another

Solution

A proxy or framework middleware only registers CORS on some routers; move the CORS handler to a global position before route-specific handlers.

Limitations

  • curl cannot execute the browser CORS algorithm, so it verifies headers, not the final allow decision.
  • Caches can serve stale preflight responses; check Access-Control-Max-Age and clear caches while iterating.

Take the browser out of the loop first

A CORS error in the console is the end of a negotiation you never saw. The browser first sends an OPTIONS request with Access-Control-Request-Method and Access-Control-Request-Headers, then checks the response against a strict algorithm. Reproducing that OPTIONS call with curl turns an opaque console error into a readable list of response headers, and the mismatch becomes obvious in seconds.

curl -s -D - -o /dev/null -X OPTIONS "https://api.example.com/v1/search" \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type,x-api-key"

Match headers exactly, not loosely

The browser compares the requested header list against Access-Control-Allow-Headers as an exact, case-insensitive membership test. A wildcard works only without credentials. When the response echoes content-type but the request also carried x-api-key, the missing entry is the whole problem, and the fix is one line in the allow list.

Fix the server, never the client

The temptation is to strip the custom header in the frontend to make the error disappear. That hides the symptom and breaks the API contract. CORS failures are always resolved server-side by allowing what the client legitimately sends; the client should keep sending what the API requires.