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.
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();
});
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.
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.
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.
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