Understanding CORS: Basic vs. Preflight Requests
CORS (Cross-Origin Resource Sharing) trips up developers because the rules change based on what kind of request you're making. The browser decides between "simple" requests and "preflight" requests based on specific criteria, and the server needs to respond differently.
The distinction isn't just withCredentials. The browser uses preflight when you include credentials (withCredentials: true), use HTTP methods besides GET/HEAD/POST, send custom headers (anything beyond the safe list like Content-Type: application/json), or your Content-Type is something other than application/x-www-form-urlencoded, multipart/form-data, or text/plain. If none of those apply, the browser uses a simple request.
For simple requests, the browser sends the request directly with an Origin header, and 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 this response." This works for public APIs where credentials don't matter.
A common config mistake is setting the header multiple times. Browsers concatenate duplicate headers with commas, turning Access-Control-Allow-Origin: * repeated three times into Access-Control-Allow-Origin: *, *, *, which is invalid syntax. The browser rejects it with "The 'Access-Control-Allow-Origin' header contains multiple values '*, *, *', but only one is allowed." This usually happens when multiple config layers (server config, application middleware, proxy config) each add the header without checking if it's already set.
Preflight requests and credentials
When you send credentials or use 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
Key restrictions with credentials: Access-Control-Allow-Origin can't be * when credentials are involved. The server must echo back the exact origin from the request 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 needs to read the Origin header and echo 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 into AccessControlAllowOrigin, 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 attacks.
nginx can do the same thing more concisely:
add_header "Access-Control-Allow-Origin" $http_origin always;
add_header "Access-Control-Allow-Credentials" "true" always;
The $http_origin variable contains the request's Origin header. The always flag ensures the headers are sent even for error responses (4xx/5xx), which is what the browser expects during CORS checks.
Use Access-Control-Allow-Origin: * when your API is public and doesn't require authentication, you don't send cookies or Authorization headers, or you're serving static assets. Use dynamic origin matching when you send credentials, need to support multiple specific origins, or your API requires authentication. Never return both * and credentials headers (browsers reject this), skip CORS headers on OPTIONS responses (preflight will fail), or allow credentials with Access-Control-Allow-Origin: * (not allowed).
CORS prevents malicious sites from reading your data on other domains. Without it, any website could make authenticated requests to your bank using your cookies. The complexity comes from balancing security with functionality.
If you're working with S3 and need to configure CORS for multiple origins, check out my guide on setting up CORS with the Vary-Origin header in AWS S3.