Every CORS Header Explained

You've seen bits and pieces of these headers scattered across earlier chapters. Now we're going to lay every single one of them out on the table, examine them under bright light, and make sure there are no surprises left. Consider this your reference chapter — the one you come back to when something doesn't work and you need to know exactly what a header does, what values it accepts, and what happens when you get it wrong.

There are exactly three request headers and seven response headers that make up the CORS protocol. Ten headers. That's it. The entire mechanism that governs cross-origin access on the web fits on an index card. And yet, somehow, it generates more Stack Overflow questions than most people's entire frameworks.

Let's fix that.


Request Headers

These headers are set by the browser automatically. You don't set them yourself in JavaScript — the browser adds them to cross-origin requests. If you try to set them manually with fetch() or XMLHttpRequest, the browser will either ignore you or override your values.

Origin

What it does: Tells the server where the request came from. It contains the scheme, host, and port of the page that initiated the request — nothing more.

Format:

Origin: <scheme>://<host>[:<port>]

Examples:

Origin: https://app.example.com
Origin: http://localhost:3000
Origin: https://app.example.com:8443

When it's sent:

  • On every cross-origin request (both simple and preflighted)
  • On same-origin requests that use POST, PUT, PATCH, or DELETE
  • NOT on same-origin GET or HEAD requests (usually)
  • NOT on navigation requests (clicking a link, entering a URL)

What it does NOT include: No path, no query string, no fragment. The browser deliberately strips these for privacy. The server gets https://app.example.com, never https://app.example.com/users/12345/secret-page?token=abc.

Common mistakes:

  1. Comparing Origin with a trailing slash. The Origin header never has a trailing slash. If your server checks origin === "https://example.com/", it will fail every time. The value is always https://example.com — no slash.

  2. Assuming Origin is always present. Some requests don't carry an Origin header at all. Server-to-server requests, curl commands, and certain same-origin requests won't have one. Your server-side CORS logic should handle the absence gracefully.

  3. Treating Origin as trustworthy for security. The Origin header can be trivially spoofed by non-browser clients. CORS is a browser-enforced mechanism, not a server-enforced firewall. Anyone with curl can send whatever Origin they want:

curl -H "Origin: https://definitely-not-real.com" https://api.example.com/data

The server will happily respond. CORS only protects users in browsers.


Access-Control-Request-Method

What it does: Sent in preflight (OPTIONS) requests only. Tells the server which HTTP method the actual request will use.

Format:

Access-Control-Request-Method: <method>

Example preflight for a DELETE request:

OPTIONS /api/users/42 HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: DELETE

Key details:

  • This header is singular — it contains exactly one method, even if your application might use multiple methods against the same endpoint.
  • It only appears in preflight requests, never in the actual request.
  • Simple methods (GET, HEAD, POST) can still appear here if other aspects of the request trigger a preflight (like a custom header).

Common mistake: Forgetting that your server's OPTIONS handler needs to actually read this header and respond appropriately. Many developers set up a blanket OPTIONS response without checking what method is being requested. This usually works — until it doesn't.


Access-Control-Request-Headers

What it does: Sent in preflight requests. Lists the non-simple headers that the actual request will include.

Format:

Access-Control-Request-Headers: <header>[, <header>]*

Example:

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

Key details:

  • Header names are case-insensitive and comma-separated.
  • Only non-simple headers appear here. The browser won't list Accept or Accept-Language because those are always allowed.
  • Content-Type appears here when its value is something other than application/x-www-form-urlencoded, multipart/form-data, or text/plain. So Content-Type: application/json triggers it. This is the single most common reason people encounter preflight requests.

Common mistake: Your API requires an Authorization header, but your CORS configuration doesn't include it in the allowed headers list. The preflight fails, the actual request never fires, and you see a CORS error that says nothing about Authorization. You stare at your Access-Control-Allow-Origin header for an hour before realizing the problem is in a completely different header.

We've all been there.


Response Headers

These are the headers your server sends back. Getting them right is your job.

Access-Control-Allow-Origin

What it does: The most important CORS response header. Tells the browser whether the requesting origin is allowed to read the response.

Valid values:

ValueMeaning
*Any origin can read this response
https://app.example.comOnly this specific origin
nullDon't use this. Seriously.

Example response:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json

{"users": [...]}

The wildcard *:

The wildcard means "any origin." It's perfectly appropriate for truly public APIs and resources. But it comes with restrictions:

  • You cannot use * when the request includes credentials (cookies, HTTP auth). The browser will reject the response. This is not configurable.
  • The wildcard is literal — it's the character *, not a glob pattern. You can't write *.example.com. That's not a thing. There is no subdomain wildcard in CORS.

Dynamic origin reflection:

Since you can only return one specific origin (or *), servers that need to allow multiple origins typically:

  1. Read the Origin request header
  2. Check it against an allowlist
  3. Echo it back in Access-Control-Allow-Origin
ALLOWED_ORIGINS = {
    "https://app.example.com",
    "https://staging.example.com",
    "http://localhost:3000",
}

def add_cors_headers(request, response):
    origin = request.headers.get("Origin")
    if origin in ALLOWED_ORIGINS:
        response.headers["Access-Control-Allow-Origin"] = origin
        response.headers["Vary"] = "Origin"  # CRITICAL - explained below

The null origin:

Some requests have an Origin of null — sandboxed iframes, file:// URLs, redirected requests. Never set Access-Control-Allow-Origin: null, because any page in a sandboxed iframe would match. It's the CORS equivalent of leaving your front door open and putting a sign that says "knock first."

Common mistakes:

  1. Returning multiple origins. Access-Control-Allow-Origin: https://a.com, https://b.com is not valid. The header takes exactly one value.

  2. Reflecting the origin without validation. If your server blindly echoes back whatever Origin header it receives, congratulations — you've effectively set it to * but with extra steps, and you've probably also broken credential-based restrictions.

  3. Using a regex that's too loose. Checking origin.endsWith("example.com") also matches evil-example.com. Always anchor your patterns.


Access-Control-Allow-Methods

What it does: Returned in preflight responses. Lists the HTTP methods the server allows for cross-origin requests.

Format:

Access-Control-Allow-Methods: <method>[, <method>]*

Example:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH

Key details:

  • This header only matters in preflight responses. The browser checks it against the Access-Control-Request-Method from the preflight request.
  • Listing a method here doesn't mean your endpoint actually supports it — that's still up to your router. This header just tells the browser it's allowed to try.
  • The wildcard * is allowed (when credentials are not involved), and it means all methods. But many developers prefer listing methods explicitly for clarity.

Common mistake: Forgetting to include the actual method. Your API supports PATCH, your route handles PATCH, but your CORS middleware only allows GET, POST, PUT, DELETE. The preflight fails. The error message says "CORS" and you spend 30 minutes looking at Allow-Origin before checking the methods list.


Access-Control-Allow-Headers

What it does: Returned in preflight responses. Lists the request headers the server will accept on cross-origin requests.

Format:

Access-Control-Allow-Headers: <header>[, <header>]*

Example:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type, Authorization, X-Request-ID

Key details:

  • Header names are case-insensitive.
  • The wildcard * works (without credentials) and allows any header name.
  • CORS-safelisted headers (Accept, Accept-Language, Content-Language, Content-Type with simple values) don't need to be listed, but listing them doesn't hurt.

Common mistake: You add a new custom header to your API client — say, X-Trace-ID for distributed tracing — and forget to add it to your server's allowed headers. Everything works in Postman. Everything works in curl. Everything breaks in the browser. You file a bug against the frontend team. The frontend team sends you a link to this chapter.

Here's a quick diagnostic with curl to simulate what the browser does:

# Simulate the preflight the browser would send
curl -X OPTIONS https://api.example.com/data \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, X-Trace-ID" \
  -v 2>&1 | grep -i "access-control"

If X-Trace-ID doesn't appear in the Access-Control-Allow-Headers response, that's your problem.


Access-Control-Expose-Headers

What it does: Tells the browser which response headers JavaScript is allowed to read. This one surprises people.

By default, JavaScript can only read these response headers from a cross-origin request:

  • Cache-Control
  • Content-Language
  • Content-Length
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma

That's it. These are called the "CORS-safelisted response headers." Everything else — your custom X-Request-ID, your ETag, your Link header for pagination — is invisible to JavaScript unless you explicitly expose it.

Format:

Access-Control-Expose-Headers: <header>[, <header>]*

Example:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Expose-Headers: X-Request-ID, X-RateLimit-Remaining, ETag
X-Request-ID: req-abc-123
X-RateLimit-Remaining: 47
ETag: "v42"
Content-Type: application/json

Without that Access-Control-Expose-Headers line, JavaScript would get undefined when trying to read response.headers.get("X-Request-ID"), even though the header is right there in the network tab. The header is present on the wire. The browser received it. It just won't let your JavaScript see it.

Debugging this in DevTools:

Open the Network tab, click on the request, look at the Response Headers section. You'll see all the headers. Then in the Console, try:

const res = await fetch("https://api.example.com/data");
console.log(res.headers.get("X-Request-ID"));  // null — unless exposed

The headers exist in the Network tab but not in JavaScript. This is one of the most confusing CORS behaviors for developers encountering it for the first time.

Common mistake: Building a pagination system that uses Link headers (like GitHub's API), then wondering why your frontend can't read them. The fix is one header on the server, but the symptom looks like the header doesn't exist.


Access-Control-Max-Age

What it does: Tells the browser how long (in seconds) it can cache the preflight response. We'll cover this in depth in Chapter 9, but here's the quick reference.

Format:

Access-Control-Max-Age: <seconds>

Example:

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

Key details:

  • 86400 = 24 hours. A reasonable production value.
  • 0 = don't cache preflights (useful for debugging).
  • -1 = disable caching (some browsers).
  • Browsers impose their own maximums. Chrome caps at 7200 seconds (2 hours). Firefox allows up to 86400 (24 hours). Your Max-Age: 604800 (one week) will be silently clamped.
  • If this header is absent, browsers use their own defaults — which vary. Firefox defaults to 24 hours, Chrome to 5 seconds. Yes, 5 seconds.

Common mistake: Setting Max-Age: 86400 and wondering why Chrome still sends preflights every couple of hours. Chrome's cap is 7200. You can't override it.


Access-Control-Allow-Credentials

What it does: Tells the browser it's OK to include credentials (cookies, HTTP auth, TLS client certificates) in cross-origin requests and to expose the response to JavaScript.

Valid values: true — that's it. There is no false value. If you don't want to allow credentials, omit the header entirely.

Format:

Access-Control-Allow-Credentials: true

Example:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=None
Content-Type: application/json

The big constraint: When this header is true, you cannot use the wildcard * for any of these headers:

  • Access-Control-Allow-Origin — must be a specific origin
  • Access-Control-Allow-Methods — must list specific methods
  • Access-Control-Allow-Headers — must list specific headers
  • Access-Control-Expose-Headers — must list specific headers

The browser will reject the response if it sees Allow-Credentials: true alongside any wildcards. This is the source of approximately 40% of all CORS questions on the internet.

Common mistake:

# This WILL NOT WORK
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

The browser error will be something like:

Access to fetch at 'https://api.example.com' from origin 'https://app.example.com' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

Chapter 8 covers credentials in much more detail.


Vary: Origin

This isn't technically a CORS-specific header — Vary is a standard HTTP caching header. But it's so critical to correct CORS behavior that leaving it out of this chapter would be malpractice.

What it does: Tells caches (CDNs, proxies, browser cache) that the response varies depending on the Origin request header. Different origins may get different Access-Control-Allow-Origin values, so caches must not serve a response cached for one origin to a request from a different origin.

Format:

Vary: Origin

Or combined with other Vary values:

Vary: Origin, Accept-Encoding

When you MUST include it:

Any time your Access-Control-Allow-Origin value is dynamic — meaning it changes based on the Origin request header. If you echo back the requesting origin from an allowlist, you need Vary: Origin.

What happens without it:

Here's the nightmare scenario:

  1. User visits https://app-a.example.com, which makes a cross-origin request to https://api.example.com/data.
  2. The server responds with Access-Control-Allow-Origin: https://app-a.example.com.
  3. A CDN caches this response.
  4. Another user visits https://app-b.example.com, which makes the same request to https://api.example.com/data.
  5. The CDN serves the cached response, which still says Access-Control-Allow-Origin: https://app-a.example.com.
  6. The browser sees that app-a doesn't match app-b, blocks the response.
  7. Your monitoring lights up. Users of app-b get errors. The CDN cache TTL is 1 hour. You wait, sweating.

Adding Vary: Origin prevents step 5. The CDN knows to cache separate copies for different Origin values.

When you DON'T need it:

If your Access-Control-Allow-Origin is always * (a constant, not dynamic), the response is the same regardless of origin. Technically, Vary: Origin isn't required. But including it anyway is harmless and defensive.

Common mistakes:

  1. Forgetting it entirely. This is the most common CORS caching bug in production. Everything works in development (no CDN), everything breaks intermittently in production. "Intermittently" because it depends on which origin populates the cache first.

  2. Setting Vary: *. This tells caches the response varies on everything and is effectively uncacheable. That might be what you want, but it's a sledgehammer.

  3. Not including it in preflight responses. OPTIONS responses with dynamic Allow-Origin values need Vary: Origin too. CDNs can cache OPTIONS responses.

Verifying with curl:

# Check if Vary: Origin is present
curl -s -D - -o /dev/null https://api.example.com/data \
  -H "Origin: https://app.example.com" | grep -i "vary"

You should see something like:

Vary: Origin

or:

Vary: Origin, Accept-Encoding

If you don't see Origin in the Vary header and your server uses dynamic origin matching, you have a caching bug waiting to happen.


Quick Reference Table

HeaderDirectionPresent InRequired?
OriginRequestAll cross-origin requestsAuto (browser)
Access-Control-Request-MethodRequestPreflight onlyAuto (browser)
Access-Control-Request-HeadersRequestPreflight onlyAuto (browser)
Access-Control-Allow-OriginResponsePreflight + ActualYes
Access-Control-Allow-MethodsResponsePreflight onlyFor non-simple methods
Access-Control-Allow-HeadersResponsePreflight onlyFor non-simple headers
Access-Control-Expose-HeadersResponseActual onlyIf JS needs custom headers
Access-Control-Max-AgeResponsePreflight onlyOptional
Access-Control-Allow-CredentialsResponsePreflight + ActualIf using credentials
Vary: OriginResponsePreflight + ActualIf origin is dynamic

Putting It All Together

Here's a complete preflight exchange with every relevant header, annotated:

Preflight request (sent by the browser automatically):

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

Preflight response (sent by your server):

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: 3600
Access-Control-Allow-Credentials: true
Vary: Origin

Actual request (sent by the browser after preflight succeeds):

PUT /api/users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Cookie: session=abc123

{"name": "Updated Name"}

Actual response:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Request-ID, ETag
Vary: Origin
Content-Type: application/json
X-Request-ID: req-789
ETag: "v43"

{"id": 42, "name": "Updated Name"}

Note that Access-Control-Allow-Origin, Access-Control-Allow-Credentials, and Vary: Origin appear in both the preflight response and the actual response. The preflight unlocks the right to send the request. The actual response headers control whether JavaScript can read the result.


DevTools Cheat Sheet

When debugging CORS headers in Chrome DevTools:

  1. Network tab — Find the request. If it was preflighted, you'll see two entries: the OPTIONS request and the actual request. Check headers on both.

  2. Filter by method — Type method:OPTIONS in the Network filter bar to find preflight requests specifically.

  3. Check the Response Headers on the OPTIONS request for Allow-Methods, Allow-Headers, and Max-Age.

  4. Check the Response Headers on the actual request for Allow-Origin, Allow-Credentials, and Expose-Headers.

  5. Console tab — CORS errors appear here with (usually) helpful messages. Read them carefully. They typically tell you exactly which header is missing or wrong.

# The curl equivalent of what the browser does for a preflight:
curl -X OPTIONS https://api.example.com/api/users \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization" \
  -v 2>&1 | grep -i "< access-control\|< vary"

That's every CORS header. Ten headers, three on the request side, seven on the response side (plus Vary). If you understand all of them, you understand CORS. The rest is configuration.