CORS
Cross-Origin Resource Sharing: the browser-enforced protocol in the WHATWG Fetch Standard by which a server opts in to letting scripts on other origins read its responses, chiefly via Access-Control-Allow-Origin. It constrains the browser, not the server, so it is not access control.
Full Explanation
Cross-Origin Resource Sharing (CORS) is a browser-enforced protocol. It is defined in the WHATWG Fetch Standard. CORS lets a server declare that a response may be shared with origins other than its own. The standard describes it as "layered on top of HTTP". It is necessarily opt-in "to prevent leaking data from responses behind a firewall (intranets)". An origin is the scheme, host and port taken together (RFC 6454 section 3.2). So https://app.example.com and https://cdn.example.com are different origins. The http and https forms of one hostname are different origins too.
CORS is not authentication, not an access-control list, and not a server-side defence. CORS constrains the browser. It decides whether a script running on one origin may read a response fetched from another. It is the opt-in counterpart to the same-origin policy. Under that policy, "a web application using those APIs can only request resources from the same origin the application was loaded from unless the response from other origins includes the right CORS headers" (MDN). Adding CORS headers only widens what browsers permit. It never narrows what a server will answer.
How it works
The browser drives the exchange, not the page. On a cross-origin fetch, the browser adds an Origin request header. This header names the origin that initiated the request. The server answers with Access-Control-Allow-Origin. Its value is "the literal value of the Origin request header (which can be null) or *" (Fetch Standard). The browser compares the two values. It hands the body to the script only if they match. A mismatch does not stop the request or the response. It only stops the read.
Requests split into two classes (MDN):
- Simple requests trigger no preflight. The method must be GET, HEAD or POST. The only headers the page may set by hand are the CORS-safelisted request headers: Accept, Accept-Language, Content-Language, Range (a single range value), and Content-Type. Content-Type is restricted to application/x-www-form-urlencoded, multipart/form-data or text/plain. The browser sends these requests straight out. But the response still needs a matching
Access-Control-Allow-Originbefore the script can read it. - Preflighted requests are everything else. The browser first sends a separate OPTIONS request. That request "includes the following header: Access-Control-Request-Method" (Fetch Standard). It may also include Access-Control-Request-Headers when the real request would set non-safelisted headers. The real request is sent only if the preflight response's Access-Control-Allow-Methods and Access-Control-Allow-Headers approve it. That response is "restricted to an ok status, e.g., 200 or 204" (Fetch Standard).
Three more response headers matter. Access-Control-Max-Age sets how many seconds the browser may reuse the Allow-Methods and Allow-Headers result. The default is 5 seconds (Fetch Standard). Browsers cap the ceiling. Chromium caps it at 2 hours (7200 seconds) since v76. Firefox caps it at 24 hours (86400 seconds) (MDN). Access-Control-Expose-Headers names the response headers a script may read. Without it, a script sees only the CORS-safelisted response headers. Those are Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified and Pragma (Fetch Standard). Access-Control-Allow-Credentials governs credentialed requests. Credentials are "HTTP cookies, TLS client certificates, and authentication entries" (Fetch Standard).
Credentials tighten every rule. When the request's credentials mode is "include", Access-Control-Allow-Origin "cannot be *". The response must then also carry Access-Control-Allow-Credentials with the exact value true. That is because "true is (byte) case-sensitive". In this mode, * stops acting as a wildcard in Access-Control-Expose-Headers, Access-Control-Allow-Methods and Access-Control-Allow-Headers as well (Fetch Standard). The preflight itself never carries credentials: "for a CORS-preflight request, request's credentials mode is always same-origin". So support has to be advertised on the preflight response too.
Why it matters for a CDN
On most sites the CDN is the server on the far side of the origin boundary. The page comes from app.example.com. The fonts, JavaScript modules, images and API responses come from a CDN hostname, a different origin. So the CDN, not the application, is what has to emit the CORS headers. MDN lists what goes through CORS: fetch() and XMLHttpRequest, "Web Fonts (for cross-domain font usage in @font-face within CSS)", WebGL textures, "Images/video frames drawn to a canvas using drawImage()", and CSS Shapes from images (MDN). Webfonts are the classic trap: a missing header breaks nothing visible except the typeface.
The harder problem is that CORS collides with caching. The correct Access-Control-Allow-Origin can depend on who is asking. But a cache's whole purpose is to reuse one stored response for many askers. A static Access-Control-Allow-Origin: * is identical for everyone. It costs nothing to cache. The moment the value is derived from the request's Origin, the stored response is no longer interchangeable. It needs Vary: Origin, and Origin has to be part of the cache key. Otherwise the edge hands one site's response to another site. The Fetch Standard states the rule plainly: "If CORS protocol requirements are more complicated than setting Access-Control-Allow-Origin to * or a static origin, Vary is to be used." (Fetch Standard). The price is one cached variant per allowed origin. That fragments the object and lowers cache hit ratio.
What CDNs do
- Amazon CloudFront: you attach a response headers policy to a cache behavior. Nothing is on by default. The managed SimpleCORS policy "adds the header
Access-Control-Allow-Origin: *to all responses for simple CORS requests". The managed CORS-With-Preflight policy adds Access-Control-Allow-Methods,Access-Control-Allow-Originand Access-Control-Expose-Headers to preflight (OPTIONS) responses. But "for simple CORS requests, CloudFront adds only theAccess-Control-Allow-Originheader". Both list Override origin? as No. So "if the response that CloudFront receives from the origin includes any of these headers, CloudFront uses the received header (and its value)" and ignores the policy's value (CloudFront documentation). - Cloudflare: no CORS header is added by default. You create a response header Transform Rule, or set the header in a Worker. The documented example uses the Set static operation. It adds a header "named
Access-Control-Allow-Originwith a static wildcard value ( * ) to the HTTP response" for matching hostnames (Cloudflare documentation). - Fastly: you add an
Access-Control-Allow-Originheader object to the service. Fastly documents that "any time you specify a single origin in the Source field to ensure CORS rules are applied in vcl_deliver, the system will generate VCL under vcl_deliver". Fastly also warns separately that "objects already cached won't have this header applied until you purge them". So adding CORS to an existing distribution normally means a purge (Fastly documentation).
Watch out for
- CORS is not access control. The headers only tell a browser whether a script may read the response. Any non-browser client gets the bytes regardless. This is precisely the Fetch Standard's argument that
Access-Control-Allow-Origin: *is safe on a resource curl and wget can already fetch. The standard also says that "for resources where data is protected through IP authentication or a firewall (unfortunately relatively common still), using the CORS protocol is unsafe" (Fetch Standard). There is one asymmetry. A denied preflight does stop the browser sending the real request. But that is browser behaviour, not a server-side control. - Credentials plus wildcard fails. "If a request includes a credential (most commonly a Cookie header) and the response includes an
Access-Control-Allow-Origin: *header (that is, with the wildcard), the browser will block access to the response" (MDN). Echo the exact origin instead, with no trailing slash. The Fetch Standard's table marks https://rabbit.invalid/ as not shared, because "a serialized origin has no trailing slash" (Fetch Standard). - Credentialed sharing is the dangerous mode. "Generally speaking, both sharing responses and allowing requests with credentials is rather unsafe, and extreme care has to be taken to avoid the confused deputy problem" (Fetch Standard). Reflecting Origin back unconditionally, alongside
Access-Control-Allow-Credentials: true, shares credentialed data with every site that asks. - Two different cache failures, one fix. First, a response fetched without CORS is cached and then reused for a CORS request: "the response will lack
Access-Control-Allow-Originand the user agent will cache that response". So the script is blocked, even though the server would have sent the header (Fetch Standard). Second, a response naming one specific origin is cached and served to a different origin. The browser then rejects it.Vary: Originaddresses both problems. If the server names a single origin rather than the * wildcard, "the server should also include Origin in the Vary response header" (MDN). Conversely, when the value is always * or one fixed origin, configure the server to "always sendAccess-Control-Allow-Originin responses for the resource — for non-CORS requests as well as CORS requests — and do not use Vary". - Preflight has to be answered where the request lands. An OPTIONS preflight needs its own CORS headers and an ok status. A configuration that strips OPTIONS, or forwards it to an origin answering 401 or 405, breaks every non-simple call. The preflight is sent with credentials mode "same-origin". So it carries no cookies, and an origin that demands a session cookie on OPTIONS will always refuse it (Fetch Standard).
- Failures are nearly invisible to the page. "CORS failures result in errors but for security reasons, specifics about the error are not available to JavaScript. All the code knows is that an error occurred. The only way to determine what specifically went wrong is to look at the browser's console for details." (MDN) Expect to debug from the console and from response headers on the wire. Do not expect help from application error handling.
Best practice
- Send Access-Control-Allow-Origin: * on genuinely public static assets. It keeps a single cache entry: "since there's no variance in this header, there's nothing special in caching these responses. You can just set the TTL as you normally would" (Fastly).
- Normalize Origin at the edge before it reaches the cache. Allowlist the origins you accept, and unset everything else. Without that, "any request from an origin that you do not have a response in your cache for will cause a request to go to your backend". Also, "for each allowed origin, there's a copy of the response in the cache, which uses up space" (Fastly).
- Better still, keep the CORS logic in edge configuration rather than in the cached object. Set
Access-Control-Allow-Originat delivery time from an allowlist, so the cache holds one copy. Still setVary: Originon the response, "to make sure that any caches between your Varnish and the browser, which you have no control over, still do the right thing" (Fastly). - When credentials are involved there is no shortcut: echo the exact requesting origin, send
Access-Control-Allow-Credentials: true, addVary: Origin, and restrict the allowlist to origins you actually trust (Fetch Standard). - Answer OPTIONS preflight at the edge: "browsers send OPTIONS requests before performing cross-origin POSTs. You can answer these requests directly from the edge" (Fastly). Respond with an ok status, the Allow-* headers, and an Access-Control-Max-Age the browser will honour rather than clamp.
- Verify against the CDN hostname, not the origin. Send an OPTIONS request and a normal GET through the edge. Repeat once the object is a cache hit. Confirm
Access-Control-Allow-Origin, Access-Control-Expose-Headers andVary: Originare all present on the cached copy.
Examples
Nginx adds CORS headers for CDN assets:
# Allow any origin for public static assets
location ~* \.(woff2?|ttf|eot|svg|css|js)$ {
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Max-Age 86400;
if ($request_method = OPTIONS) {
return 204;
}
}
# Specific origins for API (with Vary for caching)
location /api/ {
set $cors_origin "";
if ($http_origin ~* "^https://(app1|app2)\.example\.com$") {
set $cors_origin $http_origin;
}
add_header Access-Control-Allow-Origin $cors_origin;
add_header Vary Origin;
}
Frequently Asked Questions
Cross-Origin Resource Sharing: the browser-enforced protocol in the WHATWG Fetch Standard by which a server opts in to letting scripts on other origins read its responses, chiefly via Access-Control-Allow-Origin. It constrains the browser, not the server, so it is not access control.
Nginx adds CORS headers for CDN assets:
# Allow any origin for public static assets
location ~* \.(woff2?|ttf|eot|svg|css|js)$ {
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET, OPTIONS";
add_header Access-Control-Max-Age 86400;
if ($request_method = OPTIONS) {
return 204;
}
}
# Specific origins for API (with Vary for caching)
location /api/ {
set $cors_origin "";
if ($http_origin ~* "^https://(app1|app2)\.example\.com$") {
set $cors_origin $http_origin;
}
add_header Access-Control-Allow-Origin $cors_origin;
add_header Vary Origin;
}
Related CDN concepts include:
- Vary Header — Vary is the response header that names the request headers the origin used to select …