Modifying Request Headers at the CDN Edge Layer
Modifying request headers at the CDN edge layer lets you inject routing metadata, enrich requests with geolocation, normalize client input, and strip sensitive fields before traffic ever reaches your origin — all without a backend deploy. This guide gives you exact, runnable configuration for Cloudflare Workers, Fastly VCL, Vercel Edge Middleware, and AWS CloudFront Functions, then walks the verification and troubleshooting steps that separate a working mutation from a silent cache-poisoning incident.
Header mutation is one half of Request/Response Transformation; the other half, changing the path and destination of a request, is covered in Rewriting URLs and Paths at the Edge. Both are core building blocks of any serious Edge Routing & Serverless Function Architecture deployment, and both share the same hazard: a mutation in the wrong phase can fragment your cache and multiply origin load.
Key objectives:
- Understand header mutation timing relative to cache-key evaluation and the origin fetch
- Implement platform-specific syntax for Cloudflare, Fastly, Vercel, and AWS CloudFront
- Verify propagation end to end with
curl -I,curl -v, and edge-to-origin log correlation - Avoid cache-key poisoning, sensitive-header stripping pitfalls, and CORS preflight interference
Why mutation timing decides everything
The single most important concept is when your header mutation runs relative to cache-key derivation. CDNs compute the cache key in a strict sequence: request URL, then query parameters, then any headers named in Vary, then any custom cache-key components you have explicitly declared. A header you inject before that derivation can participate in cache segmentation. A header you inject after the lookup completes only changes the request forwarded to origin on a miss — it has zero effect on what is cached.
Get this wrong and you produce one of two failure modes. Inject a high-cardinality value (a request ID, a timestamp, a raw User-Agent) into a header that feeds the cache key and you fragment the cache: identical requests miss repeatedly and origin load climbs. Conversely, mutate a routing header after the lookup and expect it to segment the cache, and you will be baffled when two logically distinct requests collide on the same cached object.
The practical rule: perform mutations that should affect caching in the pre-cache phase (vcl_recv in Fastly, the fetch handler before caches.match in Workers, the middleware in Vercel). Perform mutations that are purely for origin routing or enrichment in a way that never touches Vary or the cache key. Always confirm the outcome by reading cf-cache-status, x-vercel-cache, or x-cache on the response.
Prerequisites and environment setup
Before you touch production, assemble a reproducible test loop:
- A non-production hostname or a preview deployment that maps to the same edge config.
curl7.75+ (for clean-wformatting) anddigfor resolver checks.- CLI access to your platform:
wranglerfor Cloudflare, thefastlyCLI for VCL services, thevercelCLI for Edge Middleware, and the AWS CLI for CloudFront Functions. - An origin you control that echoes received headers, so you can confirm propagation. A two-line handler that returns inbound headers as JSON is enough.
# Confirm tooling versions before you start
curl --version | head -n1
wrangler --version
fastly version 2>/dev/null
vercel --version
Expected output: each command prints a version string. If wrangler or fastly is missing, install it before continuing — you cannot roll back a bad mutation cleanly without CLI access.
Step-by-step: implement the mutation per platform
Each edge runtime exposes a different object model. Use the exact patterns below; they avoid the immutability traps and runtime exceptions that bite first-time implementers.
Step 1 — Cloudflare Workers
Cloudflare Request objects are immutable, so you clone the headers, mutate the clone, and reconstruct the request before forwarding. Read the incoming request.cf object for edge metadata you may want to inject.
export default {
async fetch(request, env, ctx) {
const newHeaders = new Headers(request.headers);
newHeaders.set('X-Edge-Region', request.cf?.colo || 'unknown');
newHeaders.set('X-Forwarded-Country', request.cf?.country || 'XX');
newHeaders.delete('X-Internal-Token'); // strip a client-spoofable header
const modifiedRequest = new Request(request, { headers: newHeaders });
return fetch(modifiedRequest);
}
};
Expected output: the origin sees X-Edge-Region: DFW (or your colo) and never sees X-Internal-Token. Deploy with wrangler deploy; note that Cloudflare caches Worker code at the edge for roughly 60 seconds, so a rollback redeploy is not instantaneous.
Step 2 — Fastly VCL
Fastly runs VCL as a strict state machine. Assignments in vcl_recv happen before cache evaluation, which is exactly where cache-affecting mutations belong.
sub vcl_recv {
if (req.url ~ "^/api/v2/") {
set req.http.X-API-Version = "2";
set req.http.X-Edge-Processed = "true";
}
if (req.http.Cookie ~ "session_id") {
set req.http.X-Auth-Context = "authenticated";
}
}
Expected output: requests under /api/v2/ arrive at origin with X-API-Version: 2. Fastly normalizes header names to lowercase on the wire. Roll back with fastly service-version clone --version=active followed by an edit and fastly service-version activate.
Step 3 — Vercel Edge Middleware
Next.js Edge Middleware passes mutated request headers downstream through NextResponse.next({ request: { headers } }). Scope it with config.matcher so it does not run on every asset.
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const tenant = request.nextUrl.searchParams.get('tenant') || 'default';
const requestHeaders = new Headers(request.headers);
requestHeaders.set('X-Tenant-ID', tenant);
return NextResponse.next({ request: { headers: requestHeaders } });
}
export const config = { matcher: ['/api/:path*', '/app/:path*'] };
Expected output: route handlers under the matcher receive X-Tenant-ID. Limiting the matcher reduces unnecessary middleware invocations and keeps cold-start latency down.
Step 4 — AWS CloudFront Functions
CloudFront Functions use a constrained JavaScript runtime with a sub-millisecond budget and strict header-size limits. Use them for cheap, fast header tweaks; reach for Lambda@Edge when you need network calls or more memory.
function handler(event) {
var request = event.request;
request.headers['x-edge-stage'] = { value: 'prod' };
request.headers['x-forwarded-host'] = { value: request.headers.host.value };
return request;
}
Expected output: origin requests carry x-edge-stage: prod. Total request headers must stay under 8 KB or the request is rejected. Roll back through CloudFront Function versioning in the console or with aws cloudfront update-function.
Verification
Never promote a header change without confirming it propagated end to end. Run these three checks in order.
Origin verification — bypass the cache and confirm the mutated header arrives:
curl -sS -D - -o /dev/null \
-H 'Pragma: no-cache' -H 'X-Debug: true' \
https://your-domain.com/api/v2/resource
Expected output: response headers include cf-cache-status: DYNAMIC (or x-cache: Miss), confirming you reached origin, and your echo endpoint reflects X-API-Version / X-Edge-Region.
Verbose inspection — separate what you sent from what came back:
curl -v https://your-domain.com 2>&1 | grep -E "^> |^< "
Expected output: lines starting with > are outgoing request headers; lines starting with < are response headers. Compare the two to detect silent stripping of a header you set.
Log correlation — trace one request across all layers:
Inject a unique X-Request-ID at the edge, then grep it in CDN access logs, edge function logs (wrangler tail for Workers), and origin application logs.
REQ_ID=$(uuidgen)
curl -sS -H "X-Request-ID: $REQ_ID" https://your-domain.com/api/v2/resource -o /dev/null
wrangler tail --format=pretty | grep "$REQ_ID"
Expected output: the same X-Request-ID appears in every layer’s logs, proving the mutation survived the full path.
Troubleshooting
Identical requests trigger repeated cache misses.
Diagnosis: a mutation is feeding high-cardinality data into the cache key, usually via a Vary header or a custom cache-key component. Fix: move enrichment headers out of the cache-key derivation, keep mutations that must affect caching in the pre-cache phase, and confirm cf-cache-status flips from MISS to HIT on the second request.
A custom header disappears before reaching origin.
Diagnosis: a CDN security rule or proxy middleware strips unrecognized headers, or the name collides with a reserved hop-by-hop header (Connection, Keep-Alive, Proxy-Authorization). Fix: allowlist the header in your edge security policy and rename it to avoid hop-by-hop and Sec-* reserved prefixes.
curl -I shows different headers than the browser network tab.
Diagnosis: browsers auto-inject Accept, User-Agent, Sec-Fetch-*, and Cookie; curl -I sends a minimal set, and some edge logic branches on those headers. Fix: replay realistic headers — curl -v -H 'User-Agent: Mozilla/5.0...' -H 'Accept: text/html' — so edge conditionals evaluate the same way.
400 Bad Request or HTTP/2 framing errors after a mutation.
Diagnosis: the injected value contains a newline, null byte, or unescaped quote, violating RFC 7230 token rules. Fix: validate and reject malformed input at the edge before calling set(), URL-encode dynamic values, and never pass raw user input straight into a header.
Broken browser preflight after enabling header mutation.
Diagnosis: an OPTIONS preflight is being mutated or cached without echoing Access-Control-Request-Headers. Fix: short-circuit OPTIONS before the mutation and mirror the requested headers into Access-Control-Allow-Headers, or exclude preflight from the matcher entirely.
Security constraints and sanitization
Edge mutation expands your attack surface if input validation is skipped. Cap total request headers within the provider limit (commonly 8 KB–16 KB) or expect 431 Request Header Fields Too Large. Never blindly delete Authorization or Cookie unless the edge itself terminates authentication — stripping them otherwise cascades into 401 errors at origin. Sanitize every dynamic value against RFC 7230 token rules, rejecting newlines, null bytes, and unescaped quotes to prevent request smuggling and cache poisoning. When you compact several enrichment fields, prefer a single signed JWT over many loose headers, which also keeps you under size limits and pairs cleanly with Rewriting URLs and Paths at the Edge when routing decisions depend on that claim set. If you also rewrite bodies or compress responses in the same function, review how mutation order interacts with Enabling Brotli and Zstandard Compression at the Edge so a Content-Encoding change does not desync with a Vary you set upstream.
Edge cases and warnings
| Scenario | Impact | Mitigation |
|---|---|---|
| Modifying headers after cache lookup | Cache fragmentation from a Vary mismatch; higher origin load |
Execute mutations in the pre-cache phase; override cache keys explicitly if post-cache routing is required |
| Injecting values with special characters or newlines | HTTP/2 framing errors or 400 Bad Request at origin |
URL-encode values, validate against RFC 7230, drop malformed input at the edge |
| CORS preflight stripping custom headers | Broken client API calls; missing Access-Control-Allow-Headers |
Handle OPTIONS explicitly; mirror Access-Control-Request-Headers or bypass mutation for preflight |
| Header size exceeding provider limit | Request dropped at edge or silently truncated | Monitor total header size; compress enrichment into a JWT; size-check before set() |
Frequently Asked Questions
Does modifying a request header at the edge invalidate existing cached assets?
No. The cache is keyed on the original request URL plus any Vary headers, so a mutation only affects the upstream request unless you explicitly add the header to the cache key or Vary directive.
How do I verify a custom header actually reached the origin server?
Run curl -I -H 'X-Debug: true' <url> against a cache-bypassing path and inspect origin access logs, or configure the origin to echo received headers back in the response for direct confirmation.
Can I strip sensitive headers like Authorization before forwarding to origin?
Yes, but only when the edge terminates authentication itself; otherwise stripping it produces 401 errors. Gate the deletion on route, method, or a successful JWT validation at the edge.
Why does curl -I show different headers than the browser network tab?
Browsers auto-inject Accept, User-Agent, Sec-Fetch-*, and Cookie headers that curl -I omits; replay them with curl -v -H ... to make edge conditionals behave identically during testing.
Related
- Rewriting URLs and Paths at the Edge
- Enabling Brotli and Zstandard Compression at the Edge
- Request/Response Transformation
Back to Request/Response Transformation