Content is user-generated and unverified.

CORS Vary Headers: The Complete Technical Guide

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.

Understanding the specification landscape

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.

What RFC 9111 requires for HTTP 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 critical distinction: two separate caches

The WHATWG Fetch specification defines a separate CORS-preflight cache that operates completely independently from the HTTP cache. This cache:

  • Ignores Vary headers entirely
  • Uses only Access-Control-Max-Age for cache duration
  • Creates cache entries based on: origin + URL + credentials flag + method + headers
  • Exists only within the browser (not in proxies or CDNs)

This means Vary headers serve different purposes: they're irrelevant for browser preflight caching but essential for CDN/proxy caching of OPTIONS responses.

Official status of Vary headers in CORS

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:

  • Implementation discussions (Spring Framework issue #20959, Go implementations)
  • Best practice recommendations for CDN/proxy caching
  • Stack Overflow discussions as defensive measures against aggressive web caches

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.

How popular frameworks handle Vary headers

Framework implementations vary widely in their Vary header support, reflecting different interpretations of best practices versus specification requirements.

Framework comparison matrix

FrameworkVary: OriginVary: Access-Control-Request-HeadersVary: Access-Control-Request-MethodConfiguration
Express.js (cors)✅ Yes (when dynamic)✅ Yes (since v2.8.2)❌ NoAutomatic based on config
Django (django-cors-headers)✅ Yes (when dynamic)❌ No❌ NoAutomatic, not configurable
Flask (Flask-CORS)✅ Yes (when dynamic)❌ No❌ NoConfigurable via vary_header
Spring Boot✅ Yes (always)✅ Yes (preflight)✅ Yes (preflight)Not configurable
ASP.NET Core⚠️ Partial/buggy❌ No❌ NoLimited support

Express.js: most comprehensive implementation

Express.js's cors middleware (version 2.8.2+) provides the most complete Vary header support. It automatically adds:

  • Vary: Origin when origin is dynamic (multiple origins, function, or regex - not added with wildcard *)
  • Vary: Access-Control-Request-Headers in OPTIONS responses when reflecting the request headers

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:

javascript
cors({
  origin: ['https://example1.com', 'https://example2.com']  // Adds Vary: Origin
})

cors({
  origin: '*'  // Does NOT add Vary: Origin
})

Spring Boot: aggressive Vary header policy

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-Headers

This 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: specification-compliant but minimal

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:

  • Adds Vary: Origin when using CORS_ALLOWED_ORIGINS or CORS_ALLOWED_ORIGIN_REGEXES
  • Does NOT add when CORS_ALLOW_ALL_ORIGINS = True
  • Does NOT add Vary: Access-Control-Request-Headers or similar

Flask-CORS: configurable approach

Flask-CORS provides the unique vary_header option for controlling Vary header injection:

python
CORS(app, vary_header=True)   # Default - adds Vary: Origin when dynamic
CORS(app, vary_header=False)  # Completely disables Vary header

The 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: incomplete implementation

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.

Browser preflight caching in practice

The reality of browser preflight caching differs significantly from what many developers expect, with separate caches, browser-specific limits, and practical limitations.

Preflight responses are cached separately from regular HTTP

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:

  • Cache-Control headers don't affect browser preflight caching
  • Vary headers are completely ignored by the preflight cache
  • Only Access-Control-Max-Age controls cache duration
  • Cache key is based on: origin + full URL + HTTP method

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-specific cache limits create fragmentation

BrowserMaximum Cache TimeDefault (no header)Source
Firefox86400 seconds (24 hours)5 secondsMozilla source code
Chrome v76+7200 seconds (2 hours)5 secondsChromium source code
Chrome pre-v76600 seconds (10 minutes)5 secondsChromium source code
Safari/WebKit300-600 seconds (5-10 min)5 secondsWebKit 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.

RESTful APIs face inherent preflight limitations

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.

DevTools "Disable Cache" doesn't affect preflight cache

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:

  • Chromium bug #1298477
  • Mozilla bug #1569715

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.

Authorization header complications

WHATWG Fetch GitHub issue #1278 documents that Access-Control-Max-Age may not be effective when the Authorization header is set programmatically. Workarounds include:

  • Enabling credentials mode explicitly
  • Setting Access-Control-Allow-Headers: Authorization, *
  • Testing thoroughly with your specific authentication mechanism

Vary: Origin is essential, but only for CDN caching

The Vary: Origin header serves a critical but often misunderstood purpose in CORS implementations.

Why Vary: Origin matters for CDNs and proxies

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:

  1. Request from https://example.com hits the CDN
  2. CDN caches response with Access-Control-Allow-Origin: https://example.com
  3. Request from https://other.com hits the CDN
  4. CDN serves cached response (still shows example.com as allowed origin)
  5. Browser blocks the response due to origin mismatch

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 conditional Vary header anti-pattern

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):

javascript
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:

javascript
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.

When Vary: Origin is not needed

Vary: Origin should be omitted in two scenarios:

  1. Wildcard origins - When using Access-Control-Allow-Origin: *, the response doesn't vary by origin, so Vary is unnecessary and hurts cache efficiency
  2. Static single origin - When Access-Control-Allow-Origin is always set to the same static value for all responses

However, Spring Boot violates this principle by always adding Vary: Origin even with wildcards, which has been criticized as incorrect behavior that reduces cache efficiency.

Vary: Origin must be present on 304 responses

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.

Known issues and critical edge cases

Real-world CORS implementations encounter several problematic edge cases that can cause intermittent failures.

CDN-specific caching quirks

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.

Browser-specific preflight bugs

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.

Origin validation vulnerabilities

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:

html
<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: true

Performance and cost implications

Preflight 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.

Practical implementation recommendations

Based on specification requirements, framework behaviors, and real-world issues, here are actionable recommendations.

Recommended response headers for OPTIONS

For multi-origin APIs with credentials:

http
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=86400

For public APIs without credentials:

http
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=86400

Note: No Vary header needed for wildcard origin since response doesn't vary.

Web server configuration patterns

Nginx complete CORS configuration:

nginx
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:

apache
<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>

Performance optimization strategies

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:

javascript
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.

Essential implementation checklist

Critical items for every CORS implementation:

  • ✅ Include Vary: Origin if Access-Control-Allow-Origin varies by request origin
  • ✅ Set Vary header on ALL responses (not conditionally based on request headers)
  • ✅ Set Access-Control-Max-Age: 86400 for maximum browser caching
  • ✅ Validate Origins against explicit whitelist, never blindly echo request Origin
  • ✅ Handle OPTIONS requests explicitly with 204 No Content
  • ✅ Never combine Access-Control-Allow-Origin: * with credentials
  • ✅ For CDNs: Set Cache-Control: public, max-age=86400 on OPTIONS
  • ✅ Test with multiple origins and verify cache behavior
  • ✅ Purge CDN cache after CORS configuration changes
  • ✅ Test 304 responses include proper Vary and CORS headers

Security best practices from OWASP

OWASP 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.

Conclusion: specification versus reality

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.

Content is user-generated and unverified.
    CORS Vary Headers: Complete Technical Guide & Best Practices | Claude