The "Vary: Origin" header is critical for CORS implementations with caching layers, yet "Vary: Access-Control-Request-Headers" appears nowhere in official specifications. This disconnect between specification and practice has created widespread confusion about what headers are actually required for CORS OPTIONS responses. The browser's separate preflight cache ignores Vary headers entirely, controlled only by Access-Control-Max-Age, while CDN/proxy caches desperately need these headers to prevent cache poisoning.
The CORS specification landscape underwent major changes in 2020-2022 that many developers haven't caught up with. RFC 7231 and RFC 7234 were obsoleted in June 2022 by RFC 9110 and RFC 9111 respectively, and the W3C CORS specification was superseded by the WHATWG Fetch Living Standard in 2020. These changes fundamentally altered how we should think about CORS caching.
RFC 9111 (HTTP Caching) establishes the normative requirement for Vary header behavior. Section 4.1 states:
"When a cache receives a request that can be satisfied by a stored response and that stored response contains a Vary header field, the cache MUST NOT use that stored response without revalidation unless all the presented request header fields nominated by that Vary field value match those fields in the original request."
This is a MUST requirement - not optional. The Vary header tells caches which request headers affect the response, preventing incorrect cache reuse across different contexts.
The WHATWG Fetch specification defines a separate CORS-preflight cache that operates completely independently from the HTTP cache. This cache:
This means Vary headers serve different purposes: they're irrelevant for browser preflight caching but essential for CDN/proxy caching of OPTIONS responses.
Vary: Origin - The W3C CORS specification (now superseded but still referenced) stated in its Implementation Considerations section:
"Resources that wish to enable themselves to be shared with multiple Origins but do not respond uniformly with '*' must in practice generate the Access-Control-Allow-Origin header dynamically in response to every request they wish to allow. As a consequence, authors of such resources should send a Vary: Origin HTTP header or provide other appropriate control directives to prevent caching of such responses, which may be inaccurate if re-used across-origins."
This is a SHOULD recommendation (advisory guidance), not a MUST requirement. MDN documentation reinforces this:
"If the server specifies a single origin (that may dynamically change based on the requesting origin as part of an allowlist) rather than the * wildcard, then the server should also include Origin in the Vary response header to indicate to clients that server responses will differ based on the value of the Origin request header."
Vary: Access-Control-Request-Headers and Vary: Access-Control-Request-Method - These headers are not mentioned in any official specification. They appear only in:
This creates an important distinction: specification status versus practical necessity. While not required by specs, these headers prevent real caching problems in production environments with CDNs.
Framework implementations vary widely in their Vary header support, reflecting different interpretations of best practices versus specification requirements.
| Framework | Vary: Origin | Vary: Access-Control-Request-Headers | Vary: Access-Control-Request-Method | Configuration |
|---|---|---|---|---|
| Express.js (cors) | ✅ Yes (when dynamic) | ✅ Yes (since v2.8.2) | ❌ No | Automatic based on config |
| Django (django-cors-headers) | ✅ Yes (when dynamic) | ❌ No | ❌ No | Automatic, not configurable |
| Flask (Flask-CORS) | ✅ Yes (when dynamic) | ❌ No | ❌ No | Configurable via vary_header |
| Spring Boot | ✅ Yes (always) | ✅ Yes (preflight) | ✅ Yes (preflight) | Not configurable |
| ASP.NET Core | ⚠️ Partial/buggy | ❌ No | ❌ No | Limited support |
Express.js's cors middleware (version 2.8.2+) provides the most complete Vary header support. It automatically adds:
*)This behavior was added specifically to address caching issues. GitHub issue #61 documented the problem: "when configured to reflect Access-Control-Request-Headers, should also use Vary: Access-Control-Request-Headers" - fixed in version 2.8.2.
Configuration is automatic based on your origin settings:
cors({
origin: ['https://example1.com', 'https://example2.com'] // Adds Vary: Origin
})
cors({
origin: '*' // Does NOT add Vary: Origin
})Spring Framework takes the most aggressive approach, always adding Vary: Origin even with wildcard origins. The DefaultCorsProcessor adds all three Vary headers to preflight responses:
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-HeadersThis behavior has been controversial. Spring Framework issue #18378 notes this can be "detrimental for caching when response doesn't actually vary." The headers are hardcoded with no configuration option to disable them.
However, Spring also has a known bug (issue #22273) where "DefaultCorsProcessor does not set 'vary: Origin' if the request has no Cors header," which can cause caching issues when the same resource is accessed both with and without CORS.
Django's django-cors-headers package takes a minimalist approach aligned strictly with W3C CORS recommendations. Version 1.3.0 (2016) added automatic Vary: Origin "to comply with CORS specification requirements for proper caching."
The behavior is automatic and not configurable:
CORS_ALLOWED_ORIGINS or CORS_ALLOWED_ORIGIN_REGEXESCORS_ALLOW_ALL_ORIGINS = TrueFlask-CORS provides the unique vary_header option for controlling Vary header injection:
CORS(app, vary_header=True) # Default - adds Vary: Origin when dynamic
CORS(app, vary_header=False) # Completely disables Vary headerThe documentation explains: "If True, the header Vary: Origin will be returned as per the W3 implementation guidelines... If False, the Vary header will never be injected or altered."
Source code shows it only adds Vary: Origin when there are multiple origins or regex patterns, not when using wildcard or static single origin.
ASP.NET Core has the weakest Vary header support with known bugs. Issue #21988 documented that "CORS middleware should set 'Vary: Origin' header if policy has non-default IsOriginAllowed function" - this was marked as fixed but implementation remains inconsistent.
Additional issue #23218 highlights middleware ordering problems: "It is not clear that UseCORS must come before UseResponseCaching" - CORS headers can be lost if response caching middleware is incorrectly ordered.
The reality of browser preflight caching differs significantly from what many developers expect, with separate caches, browser-specific limits, and practical limitations.
MDN documentation confirms: "To cache preflight responses, the browser uses a specific cache that is separate from the general HTTP cache that the browser manages." This means:
Each HTTP method creates a separate cache entry. A PUT request to /api/users/1 and a POST request to the same URL require separate preflight requests, even if Access-Control-Max-Age is set high.
| Browser | Maximum Cache Time | Default (no header) | Source |
|---|---|---|---|
| Firefox | 86400 seconds (24 hours) | 5 seconds | Mozilla source code |
| Chrome v76+ | 7200 seconds (2 hours) | 5 seconds | Chromium source code |
| Chrome pre-v76 | 600 seconds (10 minutes) | 5 seconds | Chromium source code |
| Safari/WebKit | 300-600 seconds (5-10 min) | 5 seconds | WebKit source code |
Setting Access-Control-Max-Age: 86400 provides the best cross-browser behavior - each browser will cap it to its own maximum, ensuring optimal caching within browser limits.
Without Access-Control-Max-Age, preflight requests repeat every 5 seconds - effectively negating any performance benefit from the cache mechanism.
The per-URL nature of preflight caching creates fundamental challenges for RESTful APIs. From production experience documented in Stack Overflow discussions:
"Because of the browser's preflight cache limit of 10/120 minutes, and REST's resource URLs, the preflight cache is of limited value. There's very little you can do to limit preflights over the course of a long running app."
Each unique URL requires its own preflight: /api/users/1, /api/users/2, /api/users/3 each need separate OPTIONS requests. With browser limits of 5-120 minutes, these caches expire relatively quickly. There's no domain-level or path-prefix caching - security concerns prevent broader scope caching according to W3C mailing list discussions.
A critical debugging gotcha: Chrome and Firefox DevTools' "Disable cache" checkbox does not clear or disable the CORS preflight cache. This has been documented in:
Developers must close and reopen the browser or use Incognito/Private mode for fresh preflight testing. The preflight cache is stored in separate internal structures (sPreflightCache in Firefox) that aren't accessible through standard cache controls.
WHATWG Fetch GitHub issue #1278 documents that Access-Control-Max-Age may not be effective when the Authorization header is set programmatically. Workarounds include:
Access-Control-Allow-Headers: Authorization, *The Vary: Origin header serves a critical but often misunderstood purpose in CORS implementations.
When Access-Control-Allow-Origin is dynamically generated based on the request's Origin header, intermediate caches need Vary: Origin to create separate cache entries per origin. Without it, a catastrophic failure scenario occurs:
https://example.com hits the CDNAccess-Control-Allow-Origin: https://example.comhttps://other.com hits the CDNexample.com as allowed origin)This cache poisoning creates intermittent failures that appear random based on which origin hit the cache first - one of the hardest CORS issues to debug.
The most common Vary header misconfiguration stems from conditional logic. From Microsoft's TextSlashPlain blog:
"If your server makes a decision about what to return based on what's in a HTTP header, you need to include that header name in your Vary, even if the request didn't include that header."
Anti-pattern (wrong):
if (request.headers.origin) {
response.headers['Access-Control-Allow-Origin'] = request.headers.origin;
response.headers['Vary'] = 'Origin'; // ❌ Too late!
}This creates cache poisoning when a non-CORS request (no Origin header) populates the cache first. Subsequent CORS requests retrieve that cached response without CORS headers, causing browser blocking.
Correct pattern:
response.headers['Vary'] = 'Origin'; // ✓ Always present
if (isAllowedOrigin(request.headers.origin)) {
response.headers['Access-Control-Allow-Origin'] = request.headers.origin;
}The Vary header must be present on all responses for a resource, not just when Origin is present in the request.
Vary: Origin should be omitted in two scenarios:
Access-Control-Allow-Origin: *, the response doesn't vary by origin, so Vary is unnecessary and hurts cache efficiencyHowever, Spring Boot violates this principle by always adding Vary: Origin even with wildcards, which has been criticized as incorrect behavior that reduces cache efficiency.
An overlooked edge case: servers must include Vary: Origin on 304 Not Modified responses and update Access-Control-Allow-Origin if the Origin changed between requests. Otherwise the cached response won't be readable in the new context.
Real-world CORS implementations encounter several problematic edge cases that can cause intermittent failures.
CloudFront/AWS: CloudFront doesn't respect Vary headers by default - it requires explicit configuration to cache based on Origin header. Modern AWS Response Header Policies (SimpleCORS or CORS-With-Preflight) handle this automatically, but legacy configurations may strip CORS headers. A common gotcha: if S3 has CORS configuration AND CloudFront has a response policy, they can conflict. Best practice is removing S3 CORS config and letting CloudFront handle everything.
Cloudflare: Includes Origin in cache key by default, but legacy configurations may strip CORS headers. Cloudflare Workers provide an elegant solution for edge-based OPTIONS handling with sub-100ms response times globally, completely eliminating origin hits for preflight requests.
Azure CDN: Standard tier respects origin CORS headers, but Premium Verizon requires explicit Rules Engine configuration where origin CORS headers are ignored by default. Changes can take hours to propagate to all edge nodes.
Firefox persistent cache: Firefox's preflight cache persists across sessions and can become "stuck" with negative results for up to 24 hours. There's no user-accessible interface to clear the CORS cache - requires browser restart or using the browser console with specific commands. This creates severe debugging challenges in development.
Chrome DevTools limitations: The Network tab in Chrome DevTools doesn't always clearly indicate when a preflight response came from cache versus a fresh request, making it difficult to verify caching behavior without external tools like HTTP Toolkit.
Safari strict behavior: Safari/WebKit has historically had bugs with preflight requests on cached resources, especially involving redirects. iOS 12 had specific bugs where preflight OPTIONS requests wouldn't leave the device. While fixed in later versions, Safari remains more strict about CORS than other browsers.
PortSwigger security research documents common validation failures that lead to vulnerabilities:
Null origin exploitation - Some servers allow Origin: null without validation. Attackers can create null origins using sandboxed iframes or data: URLs:
<iframe sandbox="allow-scripts" src="data:text/html,<script>
fetch('https://api.example.com/sensitive', {credentials: 'include'})
.then(response => response.text())
.then(data => /* exfiltrate data */);
</script>"></iframe>Subdomain regex errors - Improper regex validation like if(origin.indexOf(".example.com")) allows evil.com.attacker.com to pass validation. Use properly anchored regex: ^https://([a-z0-9-]+\.)?example\.com$
Dynamic reflection without validation - Never blindly echo the Origin header without validating against an explicit whitelist. This combination is critically dangerous:
Access-Control-Allow-Origin: [untrusted request origin]
Access-Control-Allow-Credentials: truePreflight overhead can double latency and costs. Default browser behavior without Access-Control-Max-Age means preflight requests occur every 5 seconds, adding full roundtrip latency to each API call. For serverless deployments (AWS Lambda, Cloudflare Workers, Netlify Functions) that bill per invocation, uncached preflights can double your compute costs.
HTTP Toolkit research documented real-world scenarios where preflight requests took 1.5-2 seconds in production. Implementing edge caching reduced this to <100ms, significantly improving user satisfaction metrics.
Based on specification requirements, framework behaviors, and real-world issues, here are actionable recommendations.
For multi-origin APIs with credentials:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://trusted-origin.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
Vary: Origin, Access-Control-Request-Headers, Access-Control-Request-Method
Cache-Control: public, max-age=86400For public APIs without credentials:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 86400
Cache-Control: public, max-age=86400Note: No Vary header needed for wildcard origin since response doesn't vary.
Nginx complete CORS configuration:
location /api/ {
# Always set Vary first, for all responses
add_header 'Vary' 'Origin' always;
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $http_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
add_header 'Access-Control-Max-Age' 86400 always;
add_header 'Cache-Control' 'public, max-age=86400' always;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
# For actual requests
add_header 'Access-Control-Allow-Origin' $http_origin always;
}Apache with mod_headers:
<IfModule mod_headers.c>
# Always set Vary for all responses
Header merge Vary Origin
# Dynamic origin validation and reflection
SetEnvIf Origin "^https://(www\.)?example\.com$" ORIGIN=$0
Header set Access-Control-Allow-Origin %{ORIGIN}e env=ORIGIN
Header set Access-Control-Allow-Credentials "true" env=ORIGIN
# Preflight response headers
Header always set Access-Control-Allow-Methods "GET,POST,PUT,DELETE,OPTIONS"
Header always set Access-Control-Allow-Headers "Content-Type,Authorization,X-Requested-With"
Header always set Access-Control-Max-Age "86400"
</IfModule>1. Maximize Access-Control-Max-Age - Always set to 86400 seconds (24 hours). Each browser will cap to its own maximum, ensuring best possible caching.
2. Enable CDN caching with proper Vary - Set Cache-Control: public, max-age=86400 on OPTIONS responses along with appropriate Vary headers to cache at edge locations.
3. Use edge functions for OPTIONS - Cloudflare Workers, Lambda@Edge, or similar can respond to OPTIONS from edge locations without hitting your origin, reducing latency to <100ms globally:
if (request.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Max-Age": "86400",
"Cache-Control": "public, max-age=86400"
}
});
}4. Minimize preflight triggers - Use simple requests when possible (GET, POST with simple content types). Avoid custom headers for GET requests - use query parameters instead.
5. Consider same-origin proxy - For performance-critical applications, a same-origin proxy eliminates CORS entirely, removing all preflight overhead.
Critical items for every CORS implementation:
Vary: Origin if Access-Control-Allow-Origin varies by request originAccess-Control-Max-Age: 86400 for maximum browser cachingAccess-Control-Allow-Origin: * with credentialsCache-Control: public, max-age=86400 on OPTIONSOWASP guidance emphasizes several critical security principles:
Never blindly reflect Origin - Always validate against an explicit whitelist using exact string matching or properly anchored regex. The common pattern if(origin.includes(".example.com")) is dangerously insecure.
CORS is not authentication - CORS is a browser security policy, not server authentication. The Origin header can be spoofed in non-browser contexts. Implement proper application-level authentication separate from CORS checks.
CORS doesn't prevent CSRF - CORS only controls browser access to responses. The request still reaches your server, so CSRF protection remains necessary for state-changing operations.
Log blocked CORS requests - Monitor and log CORS failures for security analysis. Unexpected origins attempting access may indicate attacks or misconfigurations.
The official specifications provide minimal guidance on Vary headers for CORS, creating a gap between spec and practice. Vary: Origin is recommended but not strictly required by specifications, yet is absolutely critical in production environments with CDNs or caching layers. Vary: Access-Control-Request-Headers appears in no specification but prevents real cache poisoning issues.
The browser's separate preflight cache ignores Vary headers entirely, controlled solely by Access-Control-Max-Age with browser-specific limits ranging from 2-24 hours. Meanwhile, CDN/proxy caching of OPTIONS responses desperately needs Vary headers to function correctly.
Framework implementations reflect this confusion, with only Express.js and Spring Boot providing comprehensive Vary header support. Most frameworks correctly add Vary: Origin for dynamic origins but omit the Access-Control-Request-* variants.
For production deployments, always include Vary: Origin when dynamically generating Access-Control-Allow-Origin, set Access-Control-Max-Age to 86400 seconds, and configure CDN caching with appropriate Cache-Control headers. Test thoroughly with multiple origins and cache scenarios, as the resulting failures are intermittent and notoriously difficult to debug. The gap between specification minimalism and production requirements means developers must go beyond what specs require to build robust CORS implementations.