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

CDN cache poisoning when Vary: Origin is absent Two-column sequence diagram showing how the CDN incorrectly returns app.example.com's CORS response to admin.example.com when Vary: Origin is missing from the server response. app.example.com CDN / Cache admin.example.com OPTIONS /data (Origin: app.example.com) cache MISS → origin ACAO: app.example.com (no Vary) cached globally (key = URL only) 204 No Content ✓ OPTIONS /data (Origin: admin.example.com) cache HIT → ACAO: app.example.com ✗ CORS blocked — mismatch

Prerequisite State

Before applying this fix, confirm:

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:

How Vary: Origin changes the cache key A three-column table. Two requests differing only in their Origin header map to one identical cache key when Vary is absent, so a single entry serves both. With Vary: Origin the key includes the origin value and each request gets its own entry. Incoming request Cache key without Vary Cache key with Vary: Origin GET /data Origin: app.example.com key = /data Origin is not part of the key key = /data + Origin app.example.com GET /data Origin: admin.example.com key = /data byte-identical to the row above key = /data + Origin admin.example.com ONE cache entry whoever arrives first owns the ACAO value TWO cache entries each origin is served its own ACAO value Vary authorises nothing — it only stops one origin's response being replayed to another

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:

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:

Allowlist and Vary are independent controls A two-by-two matrix. Rows are whether the server reflects any origin or enforces an allowlist; columns are whether Vary: Origin is emitted. Adding Vary to an unvalidated reflection makes the breach more reliable rather than safer, and enforcing an allowlist without Vary produces intermittent failures between allowed origins. Server policy Vary: Origin absent Vary: Origin present No allowlist Origin reflected verbatim UNSAFE AND UNPREDICTABLE Every origin is trusted, and the cache replays one ACAO value to all of them. Two defects at once. STILL UNSAFE Segmentation is now correct, so every attacker origin reliably receives its own valid echo. Allowlist enforced before any reflection SAFE BUT FLAKY Only allowlisted origins are ever echoed, but a warm entry hands origin A's header to origin B. CORRECT The allowlist decides who may read; Vary decides who may be served a stored copy. Vary is a cache-correctness control; the allowlist is the access-control decision. Fixing either one never fixes the other, and only the bottom-right cell needs no follow-up ticket

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.