CORS failures can look unrelated: a request works without credentials, fails with them, or returns an Access-Control-Allow-Origin header that still makes the browser unhappy. They come down to two request paths, simple and preflight. The server has to answer each one correctly.
The browser uses preflight when you include credentials (withCredentials: true), use HTTP methods besides GET/HEAD/POST, send custom headers beyond the safe list like Content-Type: application/json, or your Content-Type isn’t application/x-www-form-urlencoded, multipart/form-data, or text/plain. If none of those apply, the browser sends a simple request.
For simple requests, the browser sends the request with an Origin header. The server responds with Access-Control-Allow-Origin:
GET /api/data HTTP/1.1
Origin: https://example.com
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
The wildcard (*) means any origin can read the response. It works for public APIs where credentials don’t matter.
A common mistake: setting the header multiple times. Browsers concatenate duplicate headers with commas, turning Access-Control-Allow-Origin: * repeated three times into Access-Control-Allow-Origin: *, *, *—invalid syntax. The browser rejects it. This happens when multiple config layers (server config, application middleware, proxy config) each add the header without checking if it exists.
Preflight requests and credentials
When you send credentials or custom headers, the browser issues a preflight:
OPTIONS /api/data HTTP/1.1
Origin: https://example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Authorization
Access-Control-Allow-Credentials: true
With credentials, Access-Control-Allow-Origin can’t be *. The server must echo back the exact origin and include Access-Control-Allow-Credentials: true. If the server returns Access-Control-Allow-Origin: * with withCredentials: true, the browser blocks the request.
Server configuration
To support multiple origins with credentials, Apache reads the Origin header and echoes it back:
SetEnvIf Origin "(^|\s)((https?:\/\/)?[\w-]+(\.[\w-]+)*\.?(:\d+)?(\/*)?)" AccessControlAllowOrigin=$0
Header always set Access-Control-Allow-Origin %{AccessControlAllowOrigin}e env=AccessControlAllowOrigin
<If "-z reqenv('AccessControlAllowOrigin')">
Header set Access-Control-Allow-Origin "*"
</If>
This captures the Origin header value, echoes it back in Access-Control-Allow-Origin, and falls back to * if no Origin header was sent. The regex validates the origin format to prevent header injection.
nginx does the same thing more concisely:
add_header "Access-Control-Allow-Origin" $http_origin always;
add_header "Access-Control-Allow-Credentials" "true" always;
$http_origin contains the request’s Origin header. The always flag sends headers even for error responses (4xx/5xx), which the browser expects during CORS checks.
Use Access-Control-Allow-Origin: * when your API is public, you don’t send cookies or Authorization headers, or you serve static assets. Use dynamic origin matching when you send credentials, support multiple specific origins, or your API requires authentication.
Warning Never combine
Access-Control-Allow-Origin: *with credentials headers. Browsers reject the combination, and debugging the failure is not obvious.
Never skip CORS headers on OPTIONS responses (preflight will fail).
CORS is deliberately strict about which sites can read a response. The frustrating part is that a server can return a perfectly good payload and the browser will still hide it when one of these headers is wrong.
For S3-specific CORS setup with the Vary-Origin header, see my guide on configuring CORS in AWS S3.
One quick signal
Did this earn your time?
Thanks. That gives me something concrete to check.



