KahWee - Web Development, AI Tools & Tech Trends

Expert takes on AI tools like Claude and Sora, modern web development with React and Vite, and tech trends. By KahWee.

Understanding CORS: Basic vs. Preflight Requests

CORS (Cross-Origin Resource Sharing) trips up developers because the rules change based on request type. The browser decides between "simple" and "preflight" requests based on specific criteria, and the server must respond differently to each.

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 this response." 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 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.

For S3-specific CORS setup with the Vary-Origin header, see my guide on configuring CORS in AWS S3.