How to Fix Missing Vary: Origin Header Breaking CORS Cache Segmentation
Symptom you will see in the browser console:
Access to XMLHttpRequest at 'https://api.example.com/data' from origin
'https://admin.example.com' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.
This error appears intermittently — working for one origin but not another — despite valid server-side CORS logic. It is a cache poisoning symptom, not a misconfigured allowlist.
Root Cause
The WHATWG Fetch Standard requires browsers to treat preflight (OPTIONS) cache entries as keyed by both URL and the requesting origin. HTTP caches at the CDN or reverse-proxy layer, however, follow RFC 9110 §12.5.5: they only segment cached responses by the fields listed in the Vary response header. When a server reflects a per-origin Access-Control-Allow-Origin value without emitting Vary: Origin, every origin maps to the same cache key. The first origin to populate the cache owns that slot; every subsequent origin receives the wrong Access-Control-Allow-Origin value and the browser rejects the preflight.
RFC 9110 names the header fields listed in Vary the secondary cache key. The primary key is the request method plus the effective request URI; the secondary key is the tuple of values for the listed fields, taken from the request that produced the stored response. A cache is only permitted to reuse a stored response when both keys match. Listing nothing means the secondary key is empty, so every request with the same method and URI is a hit — no matter what its Origin said. The spec also allows a cache to apply field-specific normalisation before comparing, but Origin is compared literally, which is exactly the byte-for-byte behaviour CORS needs.
One consequence catches people out: the cache stores the Vary value that came back with that particular response. If your allowlist branch emits Vary: Origin only on a match, the rejected-origin response is stored under an empty secondary key and can then be handed to an allowed origin. That is why Step 1 below sets the header before the branch rather than inside it.
This page is a focused fix for that exact failure. For the full reference on all Access-Control-* directives — including Access-Control-Max-Age and Access-Control-Expose-Headers — see Access-Control-* Header Directives.
Cache-Poisoning State Diagram
Prerequisite State
Before applying this fix, confirm:
- The server already performs dynamic origin validation — reflecting the incoming
Originheader only after allowlist check. Access-Control-Allow-Credentialsand exact-origin reflection are wired together (wildcard + credentials is already a spec violation handled separately under wildcard risks).- You have access to purge or invalidate the CDN cache for affected paths after deployment.
Step-by-Step Fix
Step 1. Emit Vary: Origin unconditionally on every endpoint that serves any Access-Control-* header. Place it before the allowlist check so it appears even when the origin is rejected.
Nginx — add inside the location block that handles CORS:
map $http_origin $cors_origin {
default "";
~^https://([a-z0-9-]+\.)?example\.com$ $http_origin;
}
server {
location /api/ {
add_header Vary Origin always;
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Max-Age 600 always;
return 204;
}
proxy_pass http://backend;
}
}
Step 2. Set Vary before the allowlist branch in Express/Node so it is present on both allowed and rejected responses:
const allowedOrigins = ['https://app.example.com', 'https://admin.example.com'];
app.use((req, res, next) => {
res.set('Vary', 'Origin'); // always first
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Credentials', 'true');
}
next();
});
The single line res.set('Vary', 'Origin') is doing something quite specific: it changes the shape of the key the cache computes for this resource, from the URL alone to the URL paired with the request’s Origin value:
Step 3. Purge the CDN cache for every path that serves CORS responses. Entries cached before the Vary header was present use a URL-only cache key; they will continue to poison requests until evicted.
Step 4. If you are using Cloudflare, AWS CloudFront, or Fastly, confirm the CDN respects the Vary header and is not configured to strip or ignore it. Some CDNs require explicit cache-key configuration to honour Vary: Origin — consult your CDN’s cache-key override settings if purging alone does not resolve the issue.
Three framework-level details decide whether the header actually survives to the wire:
- Express and Node. Prefer
res.vary('Origin')overres.set('Vary', 'Origin').setreplaces the whole field, so a laterres.vary('Accept-Encoding')— or a compression middleware that appends its own token — can silently dropOrigin.res.vary()appends to the existing list instead. Thecorspackage addsVary: Originfor you only when the origin option is a function, an array, or a string other than*; a static wildcard config emits nothing, which is correct but means turning the wildcard into an allowlist later requires no further change. - Nginx.
add_headeris not inherited into alocationblock that declares its ownadd_headerdirectives, so aVary Originset atserverlevel disappears the moment any nested block adds a header of its own. Repeat the directive in every block that emits CORS headers, and keep thealwaysflag so it survives error responses such as401and404, which are exactly the ones a cache is happy to store. - Django, Rails, and Spring. Django’s
@vary_on_headers('Origin')and Rails’response.headers['Vary']both merge rather than replace, but a view that returns a cachedHttpResponseobject reuses whateverVarywas attached when the object was built. Spring’sCorsFilteraddsVary: Origin, Access-Control-Request-Method, Access-Control-Request-Headersautomatically; if you also configure CORS at a gateway in front of it you will see the field twice, which is harmless but worth knowing before you go hunting for a bug.
At the edge, the failure mode differs by product. CloudFront ignores Vary for cache-key purposes and requires Origin to be added to the origin request policy’s header allowlist instead; Cloudflare’s default cache does not vary on arbitrary request headers on the standard plans, so a Worker that reads request.headers.get('Origin') and writes the response into the Cache API under a per-origin key is the usual workaround. Fastly and Varnish honour Vary natively, but a VCL snippet that normalises or removes request headers in vcl_recv will flatten the segmentation before the lookup happens.
Verification
Run both curl commands and confirm each response contains the correct origin-specific Access-Control-Allow-Origin:
curl -sI -X OPTIONS \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: POST' \
https://api.example.com/data | grep -E '^(vary|access-control)'
curl -sI -X OPTIONS \
-H 'Origin: https://admin.example.com' \
-H 'Access-Control-Request-Method: POST' \
https://api.example.com/data | grep -E '^(vary|access-control)'
Each response must show:
DevTools check:
Security Boundary Note
Never reflect the raw Origin header without allowlist validation. Vary: Origin + unrestricted reflection (Access-Control-Allow-Origin: ${req.headers.origin} without a check) makes your API callable from any origin with full credential access. The Vary header segments cache keys — it does not restrict which origins are permitted. Allowlist enforcement must happen independently and before any reflection occurs.
Because the two controls are independent, crossing them gives four distinct outcomes — and only one of them is a deployable configuration:
Common Mistakes
| Mistake | Technical impact | Fix |
|---|---|---|
Omitting Vary: Origin while reflecting dynamic Access-Control-Allow-Origin |
CDN caches a single ACAO value for all origins; subsequent origins receive the first cached origin’s value | Add Vary: Origin unconditionally to all CORS responses |
Adding Vary: Origin but not purging the CDN after deployment |
Pre-existing stale entries (cached without Vary) continue to poison requests for their remaining TTL | Immediately purge all CORS paths after deploying the Vary fix |
Using Vary: * to avoid per-origin complexity |
Marks the response as uncacheable by shared caches; increases origin server load and latency | Use Vary: Origin for precise cache segmentation |
Setting Vary: Origin only on OPTIONS responses, not on the actual resource response |
CDN serves correct preflight but caches the actual GET/POST response globally; read requests from other origins receive wrong ACAO |
Apply Vary: Origin to all responses on the endpoint, not only OPTIONS |
FAQ
Does Vary: Origin work with Access-Control-Allow-Origin: *?
No. When the server returns a static wildcard value the response is identical for every origin, so there is nothing to segment by. Vary: Origin is only meaningful when the server dynamically reflects the requesting origin’s value based on an allowlist.
Why does my CDN still serve the wrong Access-Control-Allow-Origin after I added Vary: Origin?
Stale entries cached before the Vary header was added have cache keys that do not include Origin. Those entries persist until their TTL expires or you issue a cache purge. Purge the affected paths immediately after deploying the Vary fix.
Can I use Vary: * instead of Vary: Origin to be safe?
Vary: * marks the response as uncacheable by shared caches (CDNs, proxies). This eliminates the poisoning risk but destroys caching efficiency entirely. Use Vary: Origin for precise segmentation and keep the resource cacheable.
Should Vary: Origin be set on error responses as well?
Yes. A 401, 403, 404 or 500 response is cacheable under the same rules as a 200, and a rejected origin frequently produces one. If that response is stored without Vary: Origin it can later be served to an allowed origin, which then sees an inexplicable failure. In Nginx this means keeping the always flag on the add_header directive; in Express it means setting Vary in middleware that runs before your error handler, not inside the success branch.
Does Vary: Origin affect the browser’s own preflight cache?
No. The browser’s CORS-preflight cache is defined by the Fetch Standard, not by RFC 9110, and its entries are already keyed by origin, URL, credentials mode and — for header entries — the header name. Vary: Origin is ignored there. It matters only for the shared HTTP caches between the browser and your server: CDNs, reverse proxies, and the browser’s ordinary response cache for the actual request.
Related
- Access-Control-* Header Directives — parent page covering the full directive set
- Dynamic Origin Validation Patterns — allowlist strategies that pair with Vary: Origin
- Configuring CORS in Nginx for Multiple Origins — full Nginx config for multi-origin reflection
- Reducing Preflight Frequency with Header Caching — caching strategy context for preflight responses