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

  1. curl 7.68+ (any modern version) and a terminal.
  2. The failing request’s URL, method and request headers, from the browser’s network panel (“Copy as cURL” is a good starting point).
  3. The exact page origin — scheme, host and port — as the browser sends it in Origin.

What the browser does

Preflight and actual request for a cross-origin upload For a PUT with custom headers, the browser first sends an OPTIONS preflight with Origin, Access-Control-Request-Method and Access-Control-Request-Headers. The server must answer with matching Allow-Origin, Allow-Methods and Allow-Headers. Only then does the browser send the PUT, whose response must also carry Allow-Origin and expose any headers the page reads, such as ETag. Two exchanges, two places to fail browser bucket / API OPTIONS · Origin · Request-Method: PUT · Request-Headers 204 · Allow-Origin · Allow-Methods · Allow-Headers · Max-Age PUT · Origin · Content-Type · x-amz-meta-* · body 200 · Allow-Origin · Expose-Headers: ETag curl can send both requests and show every header; the browser shows only the verdict.
A passing preflight is necessary but not sufficient — the actual response needs CORS headers too.

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-algorithm added by newer AWS SDKs, or authorization on your own API.
  • Allow-Origin is * and the request sends credentials. Browsers reject a wildcard origin when credentials: "include" or withCredentials is set. Either return the specific origin (and Access-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 Origin header.
  • 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 include Origin in its cache key.

A decision table for common failures

Mapping curl observations to CORS fixes A 403 preflight without headers means no rule matched; fix the origin, method or header list. A missing header in Allow-Headers means add that header. A wildcard origin with credentials means return the exact origin or drop credentials. Missing Allow-Origin on the actual response means a proxy strips headers. A wrong origin echoed means add Vary Origin and fix the cache key. A missing ETag means add it to exposed headers. What curl shows, and what to change observation fix preflight 403, no CORS headers rule must match origin + method + all headers header absent from Allow-Headers add it (lowercase) to the rule Allow-Origin: * with credentials echo exact origin, or drop credentials no Allow-Origin on actual response proxy or CDN strips headers someone else's origin echoed Vary: Origin + origin in cache key upload OK but ETag is null in JS add ETag to ExposeHeaders Most upload CORS bugs are the second row: a header the SDK adds that the rule does not list.
Each curl observation points at one specific part of the configuration.

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

Comparing CDN and origin responses for CORS headers Send the same request with Origin A and Origin B to the CDN hostname and directly to the origin. The origin should echo each origin and send Vary Origin. If the CDN returns origin A's value for origin B, its cache key ignores Origin. Four requests isolate a caching bug direct to origin Origin A → Allow-Origin A Origin B → Allow-Origin B plus Vary: Origin through the CDN Origin A → Allow-Origin A Origin B → Allow-Origin A ✗ cache key ignores Origin If origin responses are right and CDN responses are wrong, fix the CDN's cache policy, not the bucket.
Comparing origin and CDN answers tells you which layer to fix.

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.