Debugging CORS with curl Preflight Requests
Copy the failing upload’s method, origin and request headers from DevTools, then send the same preflight yourself: curl -i -X OPTIONS "<url>" -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: PUT" -H "Access-Control-Request-Headers: content-type,x-amz-meta-owner". Read the response: a 2xx with Access-Control-Allow-Origin matching your origin, Access-Control-Allow-Methods containing your method and Access-Control-Allow-Headers containing every header you listed means the preflight passes, and the problem is in the actual request; anything missing tells you exactly which part of the CORS rule to change. Then repeat the real request with -H "Origin: …" and check that the response also carries Access-Control-Allow-Origin and exposes the headers your code reads.
Browser CORS errors are deliberately vague: the console says a request was blocked, rarely why, and the network panel hides preflight details behind filters. curl has no CORS enforcement, so it shows you exactly what the server returns, which turns guesswork into a diff between what the browser asked for and what the server allowed. This page belongs to CORS configuration for uploads in backend validation and cloud storage architecture. Provider-specific rule syntax is in fixing CORS preflight errors on S3 uploads and configuring CORS for GCS and Azure Blob uploads.
When to use this approach
- The browser console reports “blocked by CORS policy” on an upload or a request to your upload API.
- Uploads work locally (often against MinIO, which allows every origin) but fail in staging or production.
- You changed a bucket’s CORS rules and want to confirm the change is live before users test it.
Prerequisites
- curl 7.68+ (any modern version) and a terminal.
- The failing request’s URL, method and request headers, from the browser’s network panel (“Copy as cURL” is a good starting point).
- The exact page origin — scheme, host and port — as the browser sends it in
Origin.
What the browser does
Implementation
A small script that performs both steps and prints a verdict:
#!/usr/bin/env bash
# usage: cors-check.sh <url> <origin> <method> [header,header,...]
set -euo pipefail
URL="$1"; ORIGIN="$2"; METHOD="$3"; HEADERS="${4:-}"
echo "== preflight"
PRE=$(curl -s -i -X OPTIONS "$URL" \
-H "Origin: $ORIGIN" \
-H "Access-Control-Request-Method: $METHOD" \
${HEADERS:+-H "Access-Control-Request-Headers: $HEADERS"})
echo "$PRE" | sed -n '1p;/^[Aa]ccess-[Cc]ontrol/p;/^[Vv]ary/p'
status=$(echo "$PRE" | head -1 | awk '{print $2}')
allow_origin=$(echo "$PRE" | tr -d '\r' | awk -F': ' 'tolower($1)=="access-control-allow-origin"{print $2}')
allow_methods=$(echo "$PRE" | tr -d '\r' | awk -F': ' 'tolower($1)=="access-control-allow-methods"{print toupper($2)}')
allow_headers=$(echo "$PRE" | tr -d '\r' | awk -F': ' 'tolower($1)=="access-control-allow-headers"{print tolower($2)}')
fail=0
[[ "$status" =~ ^2 ]] || { echo "✗ preflight status $status (must be 2xx)"; fail=1; }
[[ "$allow_origin" == "$ORIGIN" || "$allow_origin" == "*" ]] || { echo "✗ Allow-Origin '$allow_origin' does not match $ORIGIN"; fail=1; }
[[ ",${allow_methods// /}," == *",$METHOD,"* ]] || { echo "✗ $METHOD missing from Allow-Methods '$allow_methods'"; fail=1; }
IFS=',' read -ra want <<< "${HEADERS,,}"
for h in "${want[@]}"; do
[[ -z "$h" ]] && continue
[[ ",${allow_headers// /}," == *",$h,"* || "$allow_headers" == "*" ]] || { echo "✗ header '$h' missing from Allow-Headers"; fail=1; }
done
[[ $fail -eq 0 ]] && echo "✓ preflight would pass"
echo "== actual request (HEAD with Origin)"
curl -s -I "$URL" -H "Origin: $ORIGIN" | sed -n '1p;/^[Aa]ccess-[Cc]ontrol/p'
Running it against a presigned S3 URL:
./cors-check.sh "https://media-prod.s3.eu-west-1.amazonaws.com/uploads/a.png?X-Amz-Algorithm=…" \
https://app.example.com PUT content-type,x-amz-meta-owner
# == preflight
# HTTP/1.1 403 Forbidden
# ✗ preflight status 403 (must be 2xx)
# ✗ Allow-Origin '' does not match https://app.example.com
Reading the results
- Preflight returns 403 with no CORS headers. On S3 this means no CORS rule matched the origin, method and headers together. S3 matches a whole rule or nothing, so a rule that allows your origin but not one header behaves exactly like no rule. Compare every requested header against
AllowedHeaders. - Preflight 2xx, Allow-Origin present, but a header missing. The rule allows the origin and method but not one of the request headers — commonly
x-amz-meta-*,x-amz-checksum-sha256,x-amz-sdk-checksum-algorithmadded by newer AWS SDKs, orauthorizationon your own API. - Allow-Origin is
*and the request sends credentials. Browsers reject a wildcard origin whencredentials: "include"orwithCredentialsis set. Either return the specific origin (andAccess-Control-Allow-Credentials: true) or stop sending credentials to the bucket — presigned URLs never need cookies. - Preflight passes, actual response has no Allow-Origin. Some servers only add CORS headers to OPTIONS. For storage buckets this happens when a CDN or proxy in front strips headers, or caches a response generated without an
Originheader. - Allow-Origin shows a different origin. A CDN cached a response for another site and serves it to everyone. The origin must send
Vary: Origin, and the CDN must includeOriginin its cache key.
A decision table for common failures
Finding the headers the browser really sends
The headers in your source code are not always the headers on the wire. SDKs add their own: the AWS SDK v3 adds x-amz-checksum-crc32 and x-amz-sdk-checksum-algorithm to uploads by default since early 2025, amz-sdk-invocation-id and amz-sdk-request to every call, and x-amz-user-agent in browsers. Upload libraries add headers such as tus-resumable or upload-offset. The authoritative list is the Access-Control-Request-Headers value on the failing preflight in DevTools: open the network panel, enable “All” and look for the OPTIONS request, or use “Copy as cURL” on the preflight itself and replay it.
That value is what goes into the script’s fourth argument, verbatim. It is lowercase and comma-separated, and the server must allow every entry; one missing header fails the whole preflight.
Checking caches and CDNs
Run the script twice against the CDN hostname with two different allowed origins, then twice against the origin directly. If only the CDN results differ, add Origin to the cache key (CloudFront: an origin request policy that forwards Origin plus a cache policy that includes it; Cloudflare: cache by header or bypass cache for the upload path). Also check Access-Control-Max-Age: a long value means browsers keep using an old, failing preflight result for that long after you fix the server; clear it by testing in a private window.
Testing your own upload API the same way
The bucket is only half of a direct upload. The request that fetches the presigned URL goes to your API, usually from the same page, and it fails with the same vague console message when your API’s CORS handling is wrong. Run the script against the API endpoint with the method your front end uses (typically POST) and the headers it sends — content-type for JSON bodies and authorization for bearer tokens are the usual pair. If the API uses cookies, confirm that the response echoes the exact origin and includes Access-Control-Allow-Credentials: true, because a wildcard will be rejected.
Make the check part of your deployment. A smoke test that runs the preflight against the API and against a freshly signed bucket URL after every deploy catches the common regressions — a new header added by an SDK upgrade, an origin missing after a domain change, a proxy rule that drops OPTIONS — before users see them. It takes a second to run and needs nothing but curl, which makes it easy to add to any pipeline.
Keep the expected values in one place. If the allowed origins list lives in the bucket configuration, the API middleware and the smoke test separately, the three will drift; generate all of them from a single list in your infrastructure code.
Configuration gotchas
curl says everything is fine, the browser still fails. Check that the Origin you passed is byte-for-byte the page origin, including port and https. http://localhost:5173 and http://127.0.0.1:5173 are different origins.
Presigned URL expired while debugging. An expired URL returns 403 on the actual request but can still pass preflight on S3, since preflight is not signed. Generate a fresh URL for each round of tests.
OPTIONS returns 405 on your own API. Framework routing often handles only the declared methods. Add CORS middleware that answers OPTIONS before routing, or a catch-all OPTIONS handler.
Preflight passes, the browser still shows a CORS error on a 4xx or 5xx. Error responses often skip CORS middleware, so the browser hides the real error behind a CORS message. Make sure error responses carry Access-Control-Allow-Origin too.
Verification
# After fixing the rule, the same command should report a passing preflight and CORS headers on the response.
./cors-check.sh "$FRESH_URL" https://app.example.com PUT content-type,x-amz-meta-owner,x-amz-checksum-crc32,x-amz-sdk-checksum-algorithm
# ✓ preflight would pass
# access-control-allow-origin: https://app.example.com
# access-control-expose-headers: ETag
Frequently Asked Questions
Why does a simple GET with no custom headers not preflight?
Requests using GET, HEAD or POST with only CORS-safelisted headers and content types are “simple” and skip preflight. The response still needs Access-Control-Allow-Origin for the page to read it.
Can I disable CORS in the browser to test?
You can launch a browser with web security disabled, but it hides the bug instead of revealing it. curl shows the real server behaviour without changing the client.
Should upload buckets allow * origins?
For presigned uploads it is not a security hole — the signature protects the upload — but listing your real origins keeps the configuration intentional and avoids surprises with credentials.