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, orDELETE - NOT on same-origin
GETorHEADrequests (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:
-
Comparing Origin with a trailing slash. The
Originheader never has a trailing slash. If your server checksorigin === "https://example.com/", it will fail every time. The value is alwayshttps://example.com— no slash. -
Assuming Origin is always present. Some requests don't carry an
Originheader 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. -
Treating Origin as trustworthy for security. The
Originheader can be trivially spoofed by non-browser clients. CORS is a browser-enforced mechanism, not a server-enforced firewall. Anyone with curl can send whateverOriginthey 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
AcceptorAccept-Languagebecause those are always allowed. Content-Typeappears here when its value is something other thanapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain. SoContent-Type: application/jsontriggers 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:
| Value | Meaning |
|---|---|
* | Any origin can read this response |
https://app.example.com | Only this specific origin |
null | Don'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:
- Read the
Originrequest header - Check it against an allowlist
- 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:
-
Returning multiple origins.
Access-Control-Allow-Origin: https://a.com, https://b.comis not valid. The header takes exactly one value. -
Reflecting the origin without validation. If your server blindly echoes back whatever
Originheader it receives, congratulations — you've effectively set it to*but with extra steps, and you've probably also broken credential-based restrictions. -
Using a regex that's too loose. Checking
origin.endsWith("example.com")also matchesevil-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-Methodfrom 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-Typewith 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-ControlContent-LanguageContent-LengthContent-TypeExpiresLast-ModifiedPragma
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 originAccess-Control-Allow-Methods— must list specific methodsAccess-Control-Allow-Headers— must list specific headersAccess-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:
- User visits
https://app-a.example.com, which makes a cross-origin request tohttps://api.example.com/data. - The server responds with
Access-Control-Allow-Origin: https://app-a.example.com. - A CDN caches this response.
- Another user visits
https://app-b.example.com, which makes the same request tohttps://api.example.com/data. - The CDN serves the cached response, which still says
Access-Control-Allow-Origin: https://app-a.example.com. - The browser sees that
app-adoesn't matchapp-b, blocks the response. - 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:
-
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.
-
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. -
Not including it in preflight responses.
OPTIONSresponses with dynamicAllow-Originvalues needVary: Origintoo. CDNs can cacheOPTIONSresponses.
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
| Header | Direction | Present In | Required? |
|---|---|---|---|
Origin | Request | All cross-origin requests | Auto (browser) |
Access-Control-Request-Method | Request | Preflight only | Auto (browser) |
Access-Control-Request-Headers | Request | Preflight only | Auto (browser) |
Access-Control-Allow-Origin | Response | Preflight + Actual | Yes |
Access-Control-Allow-Methods | Response | Preflight only | For non-simple methods |
Access-Control-Allow-Headers | Response | Preflight only | For non-simple headers |
Access-Control-Expose-Headers | Response | Actual only | If JS needs custom headers |
Access-Control-Max-Age | Response | Preflight only | Optional |
Access-Control-Allow-Credentials | Response | Preflight + Actual | If using credentials |
Vary: Origin | Response | Preflight + Actual | If 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:
-
Network tab — Find the request. If it was preflighted, you'll see two entries: the
OPTIONSrequest and the actual request. Check headers on both. -
Filter by method — Type
method:OPTIONSin the Network filter bar to find preflight requests specifically. -
Check the Response Headers on the
OPTIONSrequest forAllow-Methods,Allow-Headers, andMax-Age. -
Check the Response Headers on the actual request for
Allow-Origin,Allow-Credentials, andExpose-Headers. -
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.