Cross-Origin Resource Sharing (CORS) Explained

How the browser's same-origin policy works, when CORS applies, and how to configure it correctly for APIs and web applications.

The same-origin policy

Browsers enforce the same-origin policy: JavaScript on one origin (scheme + host + port) cannot read responses from a different origin. This prevents a malicious page from reading your banking data by making fetch requests to your bank's API using your cookies.

Two URLs have the same origin only if their scheme, hostname, and port all match exactly. https://example.com and https://api.example.com are different origins. So are https://example.com and http://example.com.

The same-origin policy blocks reading cross-origin responses, not sending requests. The browser still sends the request -- it just refuses to let JavaScript access the response. This distinction matters for understanding CORS.

Why CORS exists

Legitimate cross-origin requests are common: a frontend at https://app.example.com calling an API at https://api.example.com, loading fonts from a CDN, or embedding third-party widgets. CORS (Cross-Origin Resource Sharing) is the mechanism by which a server explicitly opts in to allowing specific cross-origin requests.

CORS works through HTTP headers. The server tells the browser: "I allow requests from these origins, with these methods and headers." The browser enforces this -- CORS is not a server-side firewall; it is a browser-enforced contract.

Simple requests vs preflight

Not all cross-origin requests trigger a preflight. A request is "simple" if it uses GET, HEAD, or POST with only CORS-safelisted headers and a content type of application/x-www-form-urlencoded, multipart/form-data, or text/plain. Simple requests are sent directly, and the browser checks the response headers afterward.

Everything else triggers a preflight: the browser sends an OPTIONS request first to ask the server whether the actual request is allowed.

Preflight flow

The browser sends an OPTIONS request with these headers:

OPTIONS /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization

The server responds with what it allows:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

If the preflight response allows the request, the browser proceeds with the actual request. Access-Control-Max-Age caches the preflight result so subsequent requests skip the OPTIONS step for the specified duration (in seconds).

Credentialed requests

By default, cross-origin requests do not include cookies, HTTP authentication, or TLS client certificates. To include credentials, the client must set credentials: 'include' in the fetch call, and the server must respond with:

Access-Control-Allow-Credentials: true

When credentials are included, Access-Control-Allow-Origin must not be *. The server must echo the specific requesting origin. This is a hard rule enforced by browsers -- a wildcard with credentials is silently rejected.

# Correct: echo the specific origin when credentials are needed
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

# Wrong: wildcard + credentials -- browser rejects
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Wildcard pitfalls

Access-Control-Allow-Origin: * permits any origin to read the response. This is appropriate for truly public APIs (public data feeds, CDN-hosted libraries), but it has limitations:

If you need to allow multiple specific origins but not all, you must dynamically check the Origin header against an allowlist and echo it back in the response. A static wildcard is all-or-nothing.

Common misconfigurations

Reflected Origin

Some servers blindly reflect the Origin header value in Access-Control-Allow-Origin without checking an allowlist. This is equivalent to * but worse -- it also works with credentialed requests, effectively granting any website full authenticated access to the API.

# Dangerous: reflecting Origin without validation
Access-Control-Allow-Origin: ${request.headers.origin}
Access-Control-Allow-Credentials: true

Always validate the Origin against a strict allowlist before reflecting it.

Allowing the null origin

The null origin appears in requests from sandboxed iframes, local file:// pages, and certain redirects. Allowing Access-Control-Allow-Origin: null is dangerous because attackers can craft requests with a null origin using sandboxed iframes.

Missing Vary: Origin

If your server dynamically selects the Access-Control-Allow-Origin value based on the request's Origin header, you must include Vary: Origin in the response. Without it, CDNs and browser caches may serve a cached response with the wrong origin, causing CORS failures for other requestors.

Practical examples

Nginx CORS configuration for an API

# /etc/nginx/snippets/cors.conf
map $http_origin $cors_origin {
    default "";
    "https://app.example.com"  "https://app.example.com";
    "https://staging.example.com" "https://staging.example.com";
}

server {
    location /api/ {
        if ($cors_origin = "") {
            # Origin not in allowlist -- no CORS headers
        }

        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Access-Control-Allow-Credentials true always;
        add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
        add_header Access-Control-Max-Age 86400 always;
        add_header Vary Origin always;

        if ($request_method = OPTIONS) {
            return 204;
        }
    }
}

Debugging CORS with curl

Simulate a preflight request to see what the server returns:

# Simulate a preflight for a PUT request with Authorization header
curl -v -X OPTIONS https://api.example.com/data \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: Authorization, Content-Type"

# Check a simple GET request's CORS headers
curl -v https://api.example.com/data \
  -H "Origin: https://app.example.com"

Look for Access-Control-Allow-Origin in the response. If it is missing or does not match the requesting origin, the browser will block the response. Also check that the allowed methods and headers cover what your client needs.

Check your site's HTTP headers with spectra