Technical guide
16

Technical guide · Published Aug 5, 2016

Understanding CORS: Basic vs. Preflight Requests

CORS failures that look unrelated often come down to request type, duplicate headers, or credentials. Here is how the browser treats each case.

A field-guide drawing of a black-tailed deer
In this article2 sections

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?

What was missing?

Thanks. That gives me something concrete to check.