June 29, 2026
Cache Poisoning at the Edge: How Cloudflare Workers & Vercel Edge Functions Break Everything You…
“The most dangerous place to introduce a bug is between two systems that both think the other one is handling security.”

By Disaster
7 min read
- 1 First: Why Edge Runtimes Are Architecturally Different
- 2 Attack Surface #1: Worker-Injected Headers Becoming Unkeyed Cache Inputs
- 3 Attack Surface #2: The cache.put() API Is a Direct Poisoning Primitive
- 4 Attack Surface #3: Vercel Edge Middleware and the Header Forwarding Gap
- 5 Attack Surface #4: Stale-While-Revalidate Race Conditions at the Edge
"The most dangerous place to introduce a bug is between two systems that both think the other one is handling security."
You've read James Kettle's research. You've done the PortSwigger labs. You understand unkeyed headers, cache busters, and X-Forwarded-Host tricks. You think you know web cache poisoning.
Then edge computing showed up and quietly reshuffled the entire deck.
Cloudflare Workers and Vercel Edge Functions aren't just CDNs with extra steps. They're programmable layers sitting in a position no one properly accounted for when the classic cache poisoning playbook was written. And that creates some deeply weird, largely undocumented attack surface.
Let's tear it apart.
First: Why Edge Runtimes Are Architecturally Different
Classic cache poisoning assumes a clean two-layer model:
Client → CDN Cache → Origin ServerClient → CDN Cache → Origin ServerYou exploit the gap between what the cache keys on and what the origin reflects back. Simple enough.
Edge runtimes blow this model up. The real architecture now looks like:
Client → CDN Cache → Edge Worker (programmable logic) → Origin Server
↑
This thing caches too.
And it rewrites requests.
And it makes its own cache decisions.
And nobody's sure exactly when.Client → CDN Cache → Edge Worker (programmable logic) → Origin Server
↑
This thing caches too.
And it rewrites requests.
And it makes its own cache decisions.
And nobody's sure exactly when.The Edge Worker isn't passive. It intercepts, mutates, and re-emits requests. It can add headers, strip headers, rewrite URLs, and call cache.put() with whatever it wants. The CDN layer above it may or may not key on what the Worker produces vs. what the client sent.
This is where things get interesting.
Attack Surface #1: Worker-Injected Headers Becoming Unkeyed Cache Inputs
Here's a classic Cloudflare Worker pattern you'll see in the wild:
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
})
async function handleRequest(request) {
const country = request.cf.country // Cloudflare geo header
const newRequest = new Request(request, {
headers: {
...request.headers,
'X-User-Country': country,
'X-Worker-Version': '2.1'
}
})
return fetch(newRequest)
}addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request))
})
async function handleRequest(request) {
const country = request.cf.country // Cloudflare geo header
const newRequest = new Request(request, {
headers: {
...request.headers,
'X-User-Country': country,
'X-Worker-Version': '2.1'
}
})
return fetch(newRequest)
}Looks harmless. The Worker adds geo data and a version header before forwarding to origin.
Now ask: what does the origin do with X-User-Country?
If the origin reflects it — say, in a <meta> tag, a redirect URL, or a localized asset path — and the CDN cache doesn't include it in the cache key, you have a poisoning vector. But it's weirder than classic poisoning, because:
- The Worker is the one injecting the header, not you directly
- The CDN is caching the Worker's response, not the origin's raw response
- The cache key is computed on what the client sent, before the Worker touched anything
So you need to find what you can send to influence what the Worker injects. cf.country isn't spoofable — but what about other request.cf properties? What about Worker logic that reads User-Agent, Cookie, or custom headers you can control, and then passes them downstream in a way the cache doesn't key on?
The hunt: Find Worker logic that maps attacker-controlled input → injected header → reflected origin response → cached by CDN.
Attack Surface #2: The cache.put() API Is a Direct Poisoning Primitive
Cloudflare Workers give you direct access to the Cache API:
const cache = caches.default
async function handleRequest(request) {
let response = await cache.match(request)
if (!response) {
response = await fetch(request)
event.waitUntil(cache.put(request, response.clone()))
}
return response
}const cache = caches.default
async function handleRequest(request) {
let response = await cache.match(request)
if (!response) {
response = await fetch(request)
event.waitUntil(cache.put(request, response.clone()))
}
return response
}This is Worker-managed caching — the Worker explicitly decides what gets cached and under what key.
Here's the problem: cache.put(request, response) uses the request URL as the cache key by default. If the Worker constructs that request URL using attacker-controlled input without sanitization, you can poison the cache for arbitrary URLs.
Real example pattern:
// Worker reading a header to build cache key
const variant = request.headers.get('X-Experiment-Variant') || 'control'
const cacheKey = new Request(`${request.url}?variant=${variant}`, request)
await cache.put(cacheKey, response.clone())// Worker reading a header to build cache key
const variant = request.headers.get('X-Experiment-Variant') || 'control'
const cacheKey = new Request(`${request.url}?variant=${variant}`, request)
await cache.put(cacheKey, response.clone())If X-Experiment-Variant isn't stripped or validated, and other users hit a URL that resolves to the same cache key — you've just poisoned their response.
This is Cache Poisoning via cache key construction, and it's almost entirely absent from existing research because the Cache API didn't exist in traditional CDN setups.
Attack Surface #3: Vercel Edge Middleware and the Header Forwarding Gap
Vercel's Edge Middleware runs before routing and caching. A typical middleware.ts:
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const response = NextResponse.next()
// Forward client hint headers to origin
const dpr = request.headers.get('DPR')
if (dpr) {
response.headers.set('X-Client-DPR', dpr)
}
return response
}import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const response = NextResponse.next()
// Forward client hint headers to origin
const dpr = request.headers.get('DPR')
if (dpr) {
response.headers.set('X-Client-DPR', dpr)
}
return response
}This forwards the DPR (Device Pixel Ratio) client hint as a custom header. If the origin uses X-Client-DPR to serve different image variants, and Vercel's cache doesn't include it in the cache key, you have a poisoning vector.
But here's the Vercel-specific wrinkle: Vercel's caching layer sits above the middleware. So the cache may serve a cached response without ever running the middleware again. The attacker's poisoned X-Client-DPR value gets baked into the cached response and served to everyone who hits that route.
The Vary header is supposed to fix this. But in practice:
- Middleware often adds headers that aren't listed in
Vary - Vercel's ISR (Incremental Static Regeneration) has its own caching logic that can override
Varybehavior Cache-Control: s-maxageon the origin response can cause Vercel to cache aggressively regardless
Testing approach: Send requests with varied values for any header the middleware reads. Check if the response changes. Then check if that changed response gets cached and served to a request that didn't send that header.
Attack Surface #4: Stale-While-Revalidate Race Conditions at the Edge
Both Cloudflare and Vercel support stale-while-revalidate. The idea is simple: serve the stale cached response immediately, revalidate in the background.
At the edge, this creates a poisoning race:
T=0 Attacker sends poisoned request → Worker processes → response cached
T=1 Cache TTL expires → stale response still served to users
T=2 Background revalidation fires → Worker re-fetches... but from where?
T=3 If attacker can influence the revalidation request, they re-poisonT=0 Attacker sends poisoned request → Worker processes → response cached
T=1 Cache TTL expires → stale response still served to users
T=2 Background revalidation fires → Worker re-fetches... but from where?
T=3 If attacker can influence the revalidation request, they re-poisonThe attack is about poisoning the revalidation, not the initial request. During the SWR window, the cache is stale and a background request goes out to refresh it. In some Worker configurations, that background request inherits context from the last request — including headers.
This is tricky to exploit in the wild but the primitive exists. The research on SWR race conditions in edge environments is essentially zero.
Attack Surface #5: Multi-Region Edge Cache Incoherence
Cloudflare's network has 300+ PoPs. Vercel deploys to ~30 edge regions. Each region maintains its own cache.
Here's a scenario that doesn't exist in single-origin setups:
- Attacker targets
fra01(Frankfurt edge node) with a poisoned request - Legitimate users in Frankfurt get the poisoned response
- Users in
sin01(Singapore) are fine — different cache - The Frankfurt cache replicates to nearby nodes as traffic increases
This is geographically targeted cache poisoning. An attacker who knows a target's user base is concentrated in a region can poison just that region's edge cache. Security scanners checking from the US won't see anything wrong.
Tools to test this: route your requests through different VPN exit nodes and compare response hashes. If they differ, cache coherence is broken — and possibly exploitable.
Practical Recon: How to Hunt This
Here's a systematic approach for edge-specific cache poisoning recon:
Step 1: Identify the Edge Runtime
GET / HTTP/1.1
Host: target.comGET / HTTP/1.1
Host: target.comLook for response headers:
CF-Cache-Status: HIT/MISS→ CloudflareX-Vercel-Cache: HIT/MISS→ VercelX-Served-By→ FastlyServer: cloudflare→ Cloudflare Workers
Step 2: Map the Request Mutation Surface
Send a request and look at what the origin received (if you have access to origin logs, or reflect them in responses). What headers got added? What got stripped? What got rewritten?
GET /debug-echo HTTP/1.1
Host: target.com
X-Custom-Test: poisontest123GET /debug-echo HTTP/1.1
Host: target.com
X-Custom-Test: poisontest123If poisontest123 shows up anywhere in the response, you have reflection.
Step 3: Identify What's Keyed vs Unkeyed
Use Param Miner (Burp extension) — it works on edge endpoints too. But also manually test:
CF-Connecting-IP(often unkeyed)True-Client-IP(Cloudflare-specific)X-Forwarded-Host(classic, still works)X-Forwarded-Scheme(surprisingly common)- Client hints:
DPR,Width,Viewport-Width Accept-Language(localization logic)
Step 4: Test Cache API Keying
If the site uses Cloudflare Workers, look for:
- URL parameters that change the response but might share a cache key
- Cookie values that influence routing but aren't in the cache key
- Any A/B testing or experiment headers
Step 5: Verify with Timing
A cache hit is faster than a miss. Use timing differences to confirm your poisoned response is cached:
import requests, time
url = "https://target.com/path"
headers = {"X-Test": "payload"}
# Poison
r1 = requests.get(url, headers=headers)
t0 = time.time()
# Check if cached (without headers)
r2 = requests.get(url)
t1 = time.time()
print(f"Cache status: {r2.headers.get('CF-Cache-Status')}")
print(f"Response time: {t1-t0:.3f}s")
print(f"Payload in response: {'payload' in r2.text}")import requests, time
url = "https://target.com/path"
headers = {"X-Test": "payload"}
# Poison
r1 = requests.get(url, headers=headers)
t0 = time.time()
# Check if cached (without headers)
r2 = requests.get(url)
t1 = time.time()
print(f"Cache status: {r2.headers.get('CF-Cache-Status')}")
print(f"Response time: {t1-t0:.3f}s")
print(f"Payload in response: {'payload' in r2.text}")Real-World Impact
What can you actually do with this?
- XSS at scale — Poison a cached JS file reference with an attacker-controlled URL. Every user who loads the page executes your script.
- Open redirect poisoning — Inject a redirect header into a cached response. Phishing at CDN speed.
- CSP bypass — If a Worker constructs the
Content-Security-Policyheader using request input, poison it to remove restrictions. - API response poisoning — Poison cached API responses to return attacker-controlled data to mobile apps or SPAs.
The blast radius on edge cache poisoning is enormous. A single poisoned response on a high-traffic route can hit millions of users before the TTL expires.
Defenses
If you're a developer or security engineer:
- Explicitly define cache keys in your Worker/middleware. Never rely on defaults.
- Strip all non-essential headers before forwarding to origin.
- Never use attacker-controlled input in
cache.put()keys without strict validation. - Add headers you key on to
Vary— and verify your CDN respects it. - Monitor for cache anomalies — sudden changes in cached response hashes are a red flag.
- Audit your middleware for any logic that reads request headers and passes them downstream.
Wrapping Up
Edge runtimes are powerful. They're also a fundamentally new attack surface that the security community is only beginning to map.
The classic web cache poisoning playbook still works. But if your target is running Cloudflare Workers or Vercel Edge Middleware, you need to think about the Worker as a distinct layer with its own logic, its own cache access, and its own header mutations — all of which can introduce new poisoning vectors that nobody documented yet.
This is where the next wave of cache poisoning research is going to come from. The edge is the new origin.
Go find bugs.
Found something interesting in an edge runtime? Drop a comment or hit me up on X. This research area is wide open and collaboration is how we map it.
Tags: #WebSecurity #CachePoisoning #CloudflareWorkers #VercelEdge #BugBounty #AppSec #EthicalHacking #CDNSecurity