Header Deduplication Techniques

When multiple infrastructure layers each inject Access-Control-* headers independently, browsers receive response messages containing the same header name more than once. The WHATWG Fetch Standard’s preflight cache algorithm treats each unique (origin, url, credentials-mode, method, header-list) tuple as a distinct cache entry — but it does not tolerate duplicate header fields within a single response. A preflight response containing two Access-Control-Allow-Origin values fails cache storage entirely, forcing the browser to re-issue a preflight on every subsequent request regardless of your Access-Control-Max-Age setting.

This page is part of Preflight Request Optimization & Caching Strategies, which covers the full set of techniques for reducing preflight overhead across real-world deployment architectures.

Spec Anchor: What the Fetch Standard Actually Checks

The WHATWG Fetch Standard, §12.5.3 “HTTP-network-or-cache fetch”, specifies that a preflight response is only stored when the parsed header list yields exactly one value per relevant Access-Control-* field. RFC 7230 §3.2.2 allows header fields to be combined using a comma-separated list when the field definition permits it, but the CORS-specific fields — Access-Control-Allow-Origin, Access-Control-Allow-Credentials, Access-Control-Max-Age — are singleton fields that must not be combined. Any response with multiple instances of these singletons is rejected by the preflight cache.

This is separate from the browser’s origin-matching logic. Even a valid, correctly-reflected origin will not be cached if the header appears twice. Duplicate headers are the silent killer of preflight cache hit rates.

Header and Parameter Reference

Header Type Allowed values Singleton? Browser cache impact
Access-Control-Allow-Origin String Exact origin or * Yes — one value only Duplicate → cache miss every request
Access-Control-Allow-Methods Comma list HTTP method tokens No — but must be one field Multiple fields may merge; best practice: one field
Access-Control-Allow-Headers Comma list Header name tokens No — but must be one field Multiple fields increase parse surface
Access-Control-Allow-Credentials Boolean string true (no other value) Yes — one value only Duplicate or conflicting values → preflight failure
Access-Control-Max-Age Integer (seconds) Positive integer Yes — one value only Duplicate → browser uses first; cache entry may not form
Vary Comma list Header names No Must include Origin; omitting it causes CDN cache poisoning

Multi-Layer Header Collision Detection

Modern architectures inject CORS headers at multiple layers. Uncoordinated declarations create duplicate response headers that violate the Fetch Standard cache key expectations described above.

Collision mapping by infrastructure layer:

Layer Typical injection point Collision risk Detection method
Edge CDN Cache rules / WAF policies High — often duplicates origin response curl -sI via CDN, compare to origin
Reverse proxy add_header / proxy_set_header Medium — may forward upstream headers Inspect proxy access log + response dump
Application framework CORS middleware (cors npm, Spring, etc.) High — defaults often always-on Disable at one layer and retest
API gateway Route-level policies Medium — overlaps with proxy layer Gateway console + curl from gateway egress
CORS header deduplication flow Sequence diagram showing how duplicate Access-Control-Allow-Origin headers emitted by both an application server and a reverse proxy are consolidated to a single canonical header before reaching the browser. Browser Reverse Proxy App Server Origin DB / API OPTIONS /api/data forward OPTIONS 200 + ACAO: https://app.example.com proxy add_header ACAO (duplicate!) → two ACAO headers in response FIX: proxy_hide_header ACAO then add_header ACAO (one canonical) 200 + single ACAO → cache hit preflight cached ✓

Trace Access-Control-* headers through each tier before writing any fix. Audit overlapping Access-Control-Allow-Headers and Access-Control-Allow-Methods declarations. Correlate findings with OPTIONS endpoint design to ensure consistent response schemas across tiers before applying normalization.

Step-by-Step Implementation

Step 1 — Baseline the duplication rate

Run a curl preflight against each layer (direct to app, via proxy, via CDN) and count occurrences:

curl -sI -X OPTIONS https://api.example.com/v1/resource \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization" \
  | grep -ic "^access-control-allow-origin:"

A count greater than 1 confirms duplication at that layer. Record counts per layer to identify which tier is the source of truth.

Step 2 — Suppress upstream declarations at the Nginx reverse proxy

location /api/ {
  proxy_pass http://backend;

  # Strip CORS headers the backend emits — the proxy owns normalization
  proxy_hide_header Access-Control-Allow-Origin;
  proxy_hide_header Access-Control-Allow-Methods;
  proxy_hide_header Access-Control-Allow-Headers;
  proxy_hide_header Access-Control-Allow-Credentials;
  proxy_hide_header Access-Control-Max-Age;

  # Emit exactly one canonical set
  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 'Content-Type, Authorization' always;
  add_header Access-Control-Max-Age 86400 always;
  add_header Vary Origin always;
}

The always flag ensures headers are emitted on all status codes including 204 and error responses. Without it, proxy_hide_header can suppress upstream headers while add_header silently skips non-2xx responses, leaving no CORS headers at all on errors.

Step 3 — Deduplicate in Node.js / Express middleware

When the application layer runs behind a proxy that cannot strip headers (for example, a managed PaaS), intercept at the framework level:

// Deduplication middleware — mount before any CORS or routing middleware
app.use((req, res, next) => {
  const _setHeader = res.setHeader.bind(res);
  res.setHeader = (name, value) => {
    if (name.toLowerCase().startsWith('access-control-')) {
      res.removeHeader(name); // evict any prior value before setting
    }
    return _setHeader(name, value);
  };
  next();
});

// Standard cors package can then run without producing duplicates
app.use(cors({
  origin: (origin, cb) => {
    const allowed = ['https://app.example.com', 'https://admin.example.com'];
    cb(null, allowed.includes(origin) ? origin : false);
  },
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  maxAge: 86400,
}));

This interceptor ensures each CORS header is set exactly once by removing any existing value before writing, regardless of how many middleware functions attempt to set it.

Step 4 — Envoy / service-mesh deduplication via Lua filter

For service-mesh deployments using Envoy, a header-to-metadata extension captures the upstream value and a Lua filter re-injects a single normalized copy:

http_filters:
  - name: envoy.filters.http.header_to_metadata
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.http.header_to_metadata.v3.Config
      response_rules:
        - header: access-control-allow-origin
          on_header_present:
            metadata_namespace: cors_dedup
            key: origin_value
            type: STRING
          remove: true
        - header: access-control-allow-methods
          on_header_present:
            metadata_namespace: cors_dedup
            key: methods_value
            type: STRING
          remove: true
  - name: envoy.filters.http.lua
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.LuaPerRoute
      source_code:
        inline_string: |
          function envoy_on_response(response_handle)
            local meta = response_handle:streamInfo():dynamicMetadata()
            local cd = meta:get("cors_dedup")
            if cd then
              if cd["origin_value"] then
                response_handle:headers():add("access-control-allow-origin", cd["origin_value"])
              end
              if cd["methods_value"] then
                response_handle:headers():add("access-control-allow-methods", cd["methods_value"])
              end
            end
          end

This filter chain strips upstream duplicates, captures the canonical value in metadata, and re-injects exactly one normalized header before client transmission.

Step 5 — Consolidate client-side request headers

Client-side header generation directly impacts preflight trigger frequency. Each distinct combination of Access-Control-Request-Headers values produces a separate preflight cache entry. Reducing preflight frequency with header caching covers this in depth; the core practices are:

Edge Cases and Security Boundaries

Subdomain normalization. https://app.example.com and https://app.example.com:443 are the same origin per the Fetch Standard, but some frameworks serialize them differently when reflecting the request Origin. This creates phantom “distinct” Access-Control-Allow-Origin values across requests, fragmenting the preflight cache. Normalize to the canonical form (strip default ports) before reflection.

The null origin. Sandboxed iframes and file:// pages send Origin: null. Reflecting null in Access-Control-Allow-Origin: null is insecure — any sandboxed document can send this origin. If your deduplication layer reflects the request origin verbatim, add an explicit null check that returns an error rather than a reflected null.

Opaque responses. When a request is made with mode: "no-cors", the response is opaque and the browser does not inspect CORS headers. Duplicate headers in opaque responses do not trigger preflight failures, but they can confuse logging and monitoring tooling that counts CORS header occurrences.

Stripping Vary: Origin during deduplication. Some proxy configurations that suppress headers also strip Vary, because Vary is treated as a generic response metadata header. Stripping it causes CDN nodes to return the same cached response — including the same reflected Access-Control-Allow-Origin — regardless of the requesting origin. This poisons multi-tenant deployments. Always explicitly preserve or re-emit Vary: Origin as part of the normalized header set.

Over-consolidating Access-Control-Allow-Headers. Merging all allowed headers across all endpoints into one exhaustive global list invalidates targeted preflight caching for specific endpoints and increases response payload size. Maintain per-route allowed-headers lists when route security profiles differ, and align with cache duration tuning and Max-Age to ensure each route’s preflight response is cached independently.

Proxy and CDN Interaction

CDN nodes cache preflight responses independently per edge location. If a CDN caches a response that contains two Access-Control-Allow-Origin headers, every request served from that cache hit will carry duplicates — even after you fix the origin server. The fix sequence must be:

  1. Fix the origin server / proxy to emit single headers.
  2. Purge the CDN preflight cache for the affected paths (OPTIONS method, affected Origin values).
  3. Verify the CDN is forwarding Vary: Origin and using it as a cache dimension, not collapsing responses across origins.

Check CDN documentation for whether OPTIONS responses are cached by default. Cloudflare, for example, does not cache OPTIONS by default; AWS CloudFront does if you configure a Cache-Control policy that covers preflight TTL.

DevTools and curl Verification Checklist

CI/CD header count validation script:

#!/bin/bash
TARGET_URL="${1:-https://api.example.com/v1/resource}"
ORIGIN="${2:-https://app.example.com}"

RESPONSE=$(curl -sI -X OPTIONS "$TARGET_URL" \
  -H "Origin: $ORIGIN" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Content-Type, Authorization")

check_singleton() {
  local name="$1"
  local count
  count=$(printf '%s' "$RESPONSE" | grep -ic "^${name}:")
  if [ "$count" -ne 1 ]; then
    echo "FAIL: $name appears $count time(s), expected exactly 1"
    exit 1
  fi
  echo "PASS: $name ($count)"
}

check_singleton "access-control-allow-origin"
check_singleton "access-control-allow-methods"
check_singleton "access-control-allow-headers"

# Vary must include Origin
if ! printf '%s' "$RESPONSE" | grep -qi "^vary:.*origin"; then
  echo "FAIL: Vary: Origin missing from response"
  exit 1
fi

echo "PASS: all header deduplication checks passed"

Deploy this in pre-merge pipelines. Fail builds when any Access-Control-* singleton header count deviates from 1.

Common Mistakes

Issue Technical impact Mitigation
Stripping Vary: Origin after deduplication CDN collapses responses for different origins into the same cache entry; wrong Access-Control-Allow-Origin served to unrelated origins; potential credential scope leak Explicitly re-emit Vary: Origin as part of the normalized header set; confirm CDN respects Vary as a cache dimension
Case-sensitive header comparison in deduplication logic access-control-allow-origin and Access-Control-Allow-Origin treated as different fields; duplicates survive the deduplication pass Normalize header names to lowercase before comparison; RFC 7230 §3.2 specifies field names are case-insensitive
Using proxy_hide_header without add_header always Headers are stripped on 4xx/5xx responses but not re-added; cross-origin error responses fail silently in the browser Add the always flag to all CORS add_header directives in Nginx
Deduplicating per-endpoint headers into a single global list Route-specific preflight cache entries collapse into one; a preflight for /api/public grants access to /api/admin header set Maintain per-route header lists; use route-scoped CORS middleware instances rather than global configuration
Purging origin cache but not CDN preflight cache CDN edge nodes continue serving duplicated responses from cache Purge CDN cache explicitly for OPTIONS method on all affected origin paths after fixing the origin server

FAQ

Does HTTP/2 HPACK compression eliminate the need for manual header deduplication?

No. HPACK compresses repeated headers on the wire, but browsers evaluate preflight cache validity based on the parsed header list before any compression is applied. The WHATWG Fetch Standard’s preflight cache algorithm operates on the deserialized header structure, so wire-level efficiency provides no relief from application-level duplication.

How does deduplication affect Vary header caching behavior?

Removing duplicate CORS headers without maintaining Vary: Origin causes cache collisions. The Vary header instructs CDNs and shared caches to key their stored responses on the Origin header value. Always ensure Vary: Origin remains intact when normalizing Access-Control-Allow-Origin — the two are a mandatory pair in any origin-reflective CORS configuration. See dynamic origin validation patterns for the full reflection approach.

Can edge CDNs safely strip duplicate CORS headers without breaking preflight?

Yes, when configured to preserve the first valid declaration and explicitly remove upstream duplicates before response serialization to the client. The CDN must also respect Vary and not cache OPTIONS responses with mismatched origin values. Test this with a multi-origin scenario: issue preflights from two allowed origins and confirm each receives the correct reflected value from the CDN cache.

What layer should own header deduplication?

The reverse proxy or API gateway layer. Centralizing normalization before headers reach the browser ensures consistent preflight cache behavior regardless of which backend service handles the request. Application-layer deduplication often conflicts with infrastructure defaults — for example, a service mesh sidecar that injects CORS headers — and increases maintenance complexity as services scale. Treat the proxy as the single source of truth for Access-Control-* headers, and configure all upstream services to emit no CORS headers at all.

Why does case-insensitive comparison matter here specifically?

HTTP headers are case-insensitive per RFC 7230 §3.2. Different layers of a stack often use different casings: an Express middleware might emit access-control-allow-origin (lowercase), while an Nginx add_header directive emits Access-Control-Allow-Origin (title case). A naive string-equality deduplication check treats these as distinct and leaves both in the response. Always lowercase header names before comparison.