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 |
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:
- Consolidate custom headers to minimize
Access-Control-Request-Headersvariations across request types - Remove
X-Requested-Withunless a backend middleware explicitly requires it - Standardize
Authorizationtoken formatting — alwaysBearer <token>, never a variant casing - Batch custom tracking metadata into a single
X-Client-Metapayload where feasible - Freeze header order in HTTP client configuration so the serialized value is deterministic
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:
- Fix the origin server / proxy to emit single headers.
- Purge the CDN preflight cache for the affected paths (
OPTIONSmethod, affectedOriginvalues). - Verify the CDN is forwarding
Vary: Originand 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.
Related
- Preflight Request Optimization & Caching Strategies — parent section covering the full cache optimization model
- OPTIONS Endpoint Design — designing lightweight, consistent OPTIONS handlers that deduplication relies on
- Cache Duration Tuning & Max-Age — controlling preflight TTL once headers are correctly deduplicated
- Reducing Preflight Frequency with Header Caching — client-side complement to server-side normalization
- Dynamic Origin Validation Patterns — reflecting the correct origin value without introducing duplication