A service reflects the request's Origin header into the Access-Control-Allow-Origin response header, and a CDN caches those responses. Browsers on some sites start reporting CORS failures while others receive a permissive value they should not. What is missing, and why does it produce exactly this symptom?
answer
- Reflected Origin = negotiated response
- Missing Vary: Origin freezes one caller in
- Intermittent per edge node
- Preflight OPTIONS needs it too
- Append to Vary, never overwrite
basics
~20 sThe response is missing Vary: Origin. Since the body and headers depend on the request's Origin, the cache stores one copy with one site's origin baked in and replays it to requests from other origins, so those browsers see a mismatched Access-Control-Allow-Origin.
solid answer
~50 sReflecting `Origin` into `Access-Control-Allow-Origin` makes the response **negotiated on a request header**, so `Vary: Origin` is mandatory. Without it the cache key is just the URL, and the first requester's origin is frozen into the stored copy. That explains both symptoms. A browser on `https://b.example` gets a cached header saying `Access-Control-Allow-Origin: https://a.example`, the origin check fails, and the fetch is blocked — intermittently, depending on which client warmed that edge node. Meanwhile a site that should not be allowed can receive an allow header naming an origin that is not its own, or a stale allow header for an origin you have since removed from the allowlist — a real access-control problem rather than a cosmetic one. The fix is to send `Vary: Origin` on every response that reflects, including preflight `OPTIONS` responses and error responses. If the resource is public to everyone, the alternative is a constant `Access-Control-Allow-Origin: *`, which does not depend on the request and therefore needs no `Vary`.
code
http · 9 linesGET /v1/config HTTP/1.1
Host: api.example.com
Origin: https://a.example
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://a.example
Access-Control-Allow-Credentials: true
Vary: Origin, Accept-Encoding
Cache-Control: max-age=60go deeper
Recognize that echoing the request's Origin means the response depends on a request header, so the cache must be told with Vary: Origin.
Explain the intermittency — whichever caller warmed that edge determines the stored allow header — and cover preflight responses.
Frame the reverse case as an access-control weakening, verify empirically through the edge, and handle Vary append-versus-overwrite bugs in middleware ordering.
Decide the CORS surface at the edge: wildcard for public credential-free assets, reflection with a bounded allowlist elsewhere, plus a contract test that fails any reflected allow header lacking Origin in Vary.
## The mechanism being cached Cross-origin requests from a browser carry an `Origin` request header naming the calling site. The server decides whether that caller is allowed and answers with `Access-Control-Allow-Origin`. Because the header can name only one origin (or the wildcard `*`), servers supporting several allowed sites typically **reflect**: look up the request's `Origin` in an allowlist and, if it matches, echo it back. Reflection means the response's headers are a function of a request header. That is content negotiation, and every cache in the path must be told about it, exactly as with `Accept-Encoding`. ## Why the symptom looks the way it does Without `Vary: Origin`, the cache stores one response per URL. Which allow-origin value it holds depends on whoever missed the cache first — per edge node, per region, per cache generation. - A browser whose origin does not match the cached value blocks the response. The application sees a CORS error even though the server-side allowlist is correct. - Reloading may fix it (different edge node, different variant) and break again later, which is why it is often reported as flaky rather than broken. - The mirror image is worse: a site that is **not** on the allowlist can receive a stored response that carries an allow header, or an origin you removed from the allowlist keeps being allowed by cached copies until they expire. Browser same-origin protection is the thing being weakened, so this belongs in the security bucket, not the performance bucket. When credentials are involved (`Access-Control-Allow-Credentials: true`, which forbids the wildcard) the reflected value is the only option, and the missing `Vary` becomes correspondingly more serious. ## The fix, completely 1. **Emit `Vary: Origin`** on every response that reflects the origin — not only successes. Redirects, 4xx and 5xx responses that carry CORS headers need it too, since caches store those as well. 2. **Cover the preflight.** The `OPTIONS` preflight response is negotiated on `Origin`, and also on `Access-Control-Request-Method` and `Access-Control-Request-Headers` if you compute the answer from them. The full value is often `Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers`. 3. **Do not clobber existing values.** `Vary` is a list; appending `Origin` to an existing `Vary: Accept-Encoding` must produce `Vary: Accept-Encoding, Origin`. Frameworks that set rather than append are a common source of the bug — CORS middleware overwrites the compression middleware's header or vice versa, depending on ordering. 4. **Watch the edge.** Some CDNs drop or reorder request headers before computing the key, and some strip `Vary` values they do not recognize. Verify empirically by requesting the same URL twice with different `Origin` values through the same edge and comparing the returned allow header and cache-status header. ## Alternatives that avoid the variation - If the resource is genuinely public and credential-free, answer with a constant `Access-Control-Allow-Origin: *`. It does not depend on the request, so there is nothing to vary on and the cache stores a single copy — the best hit rate available. - If only two or three origins are allowed, you still reflect, but the variant count is tiny and the cost of `Vary: Origin` is negligible. - If the allowlist is large and the resource is hot, consider serving it from distinct hostnames or paths per tenant so the variation is expressed in the primary key. ## How to test for it Issue the same request twice through the cache with different `Origin` headers and inspect what comes back. If the second response echoes the first origin, or the responses lack `Vary: Origin`, the bug is present. A useful guard in CI is an assertion that any response containing `Access-Control-Allow-Origin` with a value other than `*` also contains `Origin` in its `Vary` list. ## The general rule Anything the server reads from the request to build the response must appear in `Vary` — `Origin` is simply the case people forget because CORS feels like a browser concern rather than a caching one. It is both.
- Does a preflight OPTIONS response need Vary as well?Yes. The preflight answer is computed from Origin and usually from Access-Control-Request-Method and Access-Control-Request-Headers, and preflight responses are cacheable via Access-Control-Max-Age. All inputs actually used must be listed, typically Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers.
- When can you skip Vary: Origin entirely?When the allow header is a constant that does not depend on the request — in practice Access-Control-Allow-Origin: *, which is only permitted for requests without credentials. Then every caller gets the same bytes and a single cache entry is correct, which is also the best possible hit rate.
saying these in an interview costs you the question
- Treating CORS as purely a browser concern with no caching implications
- Overwriting an existing Vary value instead of appending Origin to the list
- Adding Vary: Origin only on 200 responses and not on preflights or errors
- Concluding the server allowlist is broken because the failure is intermittent
- Reflecting arbitrary origins with credentials enabled and no allowlist check