Writing Technical guide

Web Development · 3 min read

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.

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 for methods outside GET/HEAD/POST or request headers outside the CORS safe list, including Authorization or Content-Type: application/json. Credentials alone do not trigger preflight. A request must satisfy all the simple-request conditions to avoid it.

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

A POST carrying an Authorization header triggers 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 return an explicitly allowed 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

For credentialed requests, accept only origins you explicitly trust. Checking that a string looks like an origin is not an allowlist; reflecting any origin with credentials lets an attacker-controlled site read authenticated responses.

Apache can match a specific origin before setting response headers:

SetEnvIf Origin "^https://app\.example\.com$" AllowedOrigin=$0
Header always set Access-Control-Allow-Origin "%{AllowedOrigin}e" env=AllowedOrigin
Header always set Access-Control-Allow-Credentials "true" env=AllowedOrigin
Header always merge Vary "Origin"

Apache’s SetEnvIf documentation explains the capture. Replace the example with an origin you control, and scope these directives to the intended API.

In nginx, define the allowlist in the http context using map:

map $http_origin $cors_origin {
  default "";
  "https://app.example.com" $http_origin;
}

Then use it in the API’s location block:

add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Vary "Origin" always;

An unlisted origin gets no Access-Control-Allow-Origin header. These snippets set response headers; they do not implement the OPTIONS handler. That handler must return the permitted methods and headers, as in the preflight response above. Keep authentication and CSRF protection in place; CORS controls browser access to responses, not whether a server receives a request.

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.

Credentials need an explicit origin 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.

Was this useful?

What was missing?

Thanks for the feedback.