Shared HTTP caches can reduce latency and origin load, but they also introduce a security boundary. A cache stores a response produced for one request and may later serve that response to another request. That reuse is correct only when both requests are equivalent for every property that can affect the response.
A dangerous configuration appears when an origin varies its response on a request property that the shared cache does not include in cache selection. An attacker can send a crafted request, cause the origin to generate attacker-influenced content, and leave that content stored under a key that ordinary visitors also use.
This class of failure is often called web cache poisoning. The central engineering rule is simple:
If an input can change a cacheable response, that input must participate in cache selection, be normalized before use, or be forbidden from affecting the response.
A cache key is a security decision
A simplified shared cache might identify objects using only the scheme, host, path, and selected query parameters:
https + example.com + /account/help + lang=enSuppose the origin also uses X-Forwarded-Host to construct an absolute script URL:
GET /account/help?lang=en HTTP/1.1
Host: example.com
X-Forwarded-Host: assets.attacker.invalidThe origin could produce:
<script src="https://assets.attacker.invalid/app.js"></script>If the cache ignores X-Forwarded-Host, it may store that response under the same key used for a normal request. Later visitors can receive the stored attacker-influenced response without sending the hostile header themselves.
The cache did not corrupt the page. It reused a response according to its configured equivalence rule. The defect is that the equivalence rule was incomplete.
Model response variance before tuning cache behavior
Start from the origin, not from the CDN configuration. List every request property that can influence a response:
- host and scheme;
- path and query parameters;
- accepted content type or language;
- cookies and authorization state;
- forwarding headers;
- device or region hints;
- feature flags;
- tenant identifiers;
- custom application headers.
For each property, decide whether it may change status, headers, or body content.
A useful invariant is:
same cache key => same security-relevant responseThis is stronger than saying two requests usually produce similar pages. If one request can produce a different redirect target, script URL, tenant identity, access-controlled body, or security header, treating both requests as one cache object is unsafe.
Prefer a small trusted input surface
Adding every header to a cache key is not a strong default. It can destroy cache efficiency and give clients a huge key space for resource exhaustion.
A better design reduces the number of request properties allowed to affect output.
For example, do not construct public URLs from arbitrary forwarding headers. Configure the application’s external origin explicitly:
type Config struct {
PublicOrigin string
}
func assetURL(cfg Config, path string) string {
return cfg.PublicOrigin + path
}If a reverse proxy supplies canonical host or scheme information, accept those values only from a trusted proxy boundary. Strip client-supplied copies before adding trusted values.
Internet
|
edge proxy
| remove inbound forwarding metadata
| add canonical metadata
v
applicationThis converts an attacker-controlled response input into infrastructure-controlled configuration.
Treat forwarding headers as untrusted at the public edge
Headers such as X-Forwarded-Host, X-Forwarded-Proto, and standardized Forwarded fields deserve special attention. Applications often consume them to reconstruct external URLs after TLS termination or proxy routing.
A browser can usually send arbitrary request headers that are not forbidden by the user agent, and non-browser clients have even fewer restrictions. An origin must not assume a forwarding header is trustworthy merely because its name looks infrastructure-related.
A robust edge pattern is:
- remove inbound forwarding fields that clients are not permitted to set;
- add values derived from the edge’s trusted connection and routing state;
- configure the application to trust forwarding metadata only from known proxy hops;
- avoid forwarding metadata entirely when static application configuration is sufficient.
The trust boundary should be explicit enough that a direct request to the origin cannot impersonate the proxy.
Query normalization must preserve meaning
CDNs often exclude marketing parameters or reorder query parameters to improve hit rates. That can be safe when excluded parameters truly do not affect the response.
It becomes unsafe when the origin still reads an excluded parameter.
Imagine a cache configured to ignore theme:
cache key: /docs?page=2
origin input: /docs?page=2&theme=compactIf theme changes executable markup, redirects, or other security-relevant output, requests with different meanings collapse into one cache object.
Normalization must happen consistently. One safe pattern is to remove ignored parameters before the request reaches application logic. Another is to prove that the application never consumes them for response generation.
Do not rely on a convention that developers will remember which parameters the CDN ignores. Encode the rule in routing, middleware, tests, or generated configuration.
Vary is useful but not universal
HTTP provides the Vary response header so an origin can indicate that representation selection depends on specified request headers.
For example:
Vary: Accept-Encoding, Accept-LanguageA compliant cache can use those fields when selecting a stored response. This is appropriate for genuine content negotiation.
Vary does not replace a complete cache design. CDN products can have custom key policies, some request properties cannot be expressed conveniently through Vary, and high-cardinality attacker-controlled values can damage cache efficiency.
Use Vary where it matches representation semantics, then verify the actual behavior of every shared caching layer in the request path.
Keep personalized responses out of shared caches
A separate but related failure occurs when authentication state changes a response but the cache treats authenticated and anonymous requests as equivalent.
Do not assume that the presence of a cookie automatically prevents shared caching. Behavior depends on cache directives and product configuration.
For sensitive personalized pages, prefer an explicit non-shared policy:
Cache-Control: private, no-storeThe exact directive should match application requirements. private prevents storage by shared caches, while no-store asks caches not to store the response at all.
For public pages that contain small personalized fragments, separate the public cacheable shell from authenticated data rather than making a shared cache infer user boundaries from a complex set of cookies.
Redirects can be poisoned too
Security reviews often focus on cached HTML, but cached redirects can be equally significant.
Consider an endpoint that builds a redirect from a request-derived host:
HTTP/1.1 302 Found
Location: https://attacker.invalid/login
Cache-Control: public, max-age=300If the response is stored under a key shared with ordinary traffic, visitors can be redirected to an attacker-controlled destination.
Review all cacheable status codes and response headers, not only 200 OK bodies. Redirect locations, content types, cross-origin headers, and security policies can all carry attacker-controlled variance.
Separate cache eligibility from cache identity
Two questions should be answered independently:
- May this response enter a shared cache?
- If it may, which requests are allowed to reuse it?
The first question is cache eligibility. The second is cache identity.
Mixing them makes reviews difficult. A route may have a perfect key but still contain private data that should never be shared. Another route may be safely public but have an incomplete key.
Document both decisions per route or route family.
A compact policy table can help:
| Route | Shared cache | Response inputs | Key policy |
|---|---|---|---|
/assets/* |
yes | path, encoding | path + encoding |
/docs |
yes | path, locale | path + normalized locale |
/profile |
no | session | private response |
/redirect |
no | validated destination | no shared storage |
The values are application-specific; the useful part is making the assumptions visible.
Test with paired requests
A single request can show that a response is cacheable, but it cannot prove that cache separation is correct. Security testing should use pairs of requests that differ in one candidate input.
For each input:
- prime the cache with value A;
- request the same resource with value B;
- inspect status, headers, body, and cache diagnostics;
- reverse the order;
- repeat after expiration or purge.
For example:
curl -i \
-H 'X-Forwarded-Host: first.invalid' \
'https://example.com/help'
curl -i \
-H 'X-Forwarded-Host: second.invalid' \
'https://example.com/help'Do this only against systems you are authorized to test. The important observation is whether a response generated from one value is served to the other request.
Useful cache diagnostics include Age, product-specific cache-status headers, and a unique origin response marker in a controlled test environment.
Add automated variance tests
Cache behavior is configuration plus application behavior, so changes on either side can break an earlier security assumption.
An integration test can represent the invariant directly:
for each security_relevant_input:
response_a = request(input = A)
response_b = request(input = B)
if response_a differs from response_b:
assert cache_identity(A) != cache_identity(B)The real test harness may need explicit purge operations, deterministic fixtures, and access to cache diagnostics. The concept is more important than the syntax: any input that changes output must not silently disappear from shared-cache identity.
Include tests for headers added by new middleware, query parameters introduced by product features, and proxy changes that alter host or scheme reconstruction.
Control cacheable error responses
Error pages can also depend on attacker-controlled input. A proxy might include a malformed host, path fragment, or upstream identifier in an error body. If that error is cacheable under a broad key, one malicious request can affect later users.
Set deliberate caching rules for 4xx and 5xx responses. If negative caching is valuable, keep its key semantics as strict as successful-response caching and ensure error templates do not reflect unsafe request data.
Purging is recovery, not prevention
A fast purge mechanism limits exposure after a bad object is discovered, but it does not repair the condition that created the object.
Operational preparation should still include:
- a documented purge path;
- access controls for purge operations;
- cache-object inspection where supported;
- logs that connect cache keys with origin requests;
- alerts for unusual cache status patterns.
After an incident, remove the poisoned object and correct the variance or eligibility rule before reopening normal caching.
Review changes across team boundaries
Cache poisoning defects often span ownership boundaries. Application developers know which inputs affect output. Platform teams know the CDN key. Security teams may know which differences are sensitive. No single configuration file contains the complete model.
Treat these changes as security-sensitive:
- adding a request header that affects rendering;
- changing external URL construction;
- excluding a query parameter from the key;
- enabling caching on a dynamic route;
- changing cookie forwarding;
- introducing a new proxy hop;
- caching redirects or errors.
A short variance review during such changes is cheaper than reconstructing cache behavior during an incident.
A practical review sequence
For each shared-cache route, use this sequence:
- enumerate request-controlled inputs visible to the origin;
- identify which inputs can alter status, headers, or body;
- remove unnecessary response dependencies;
- mark private or sensitive responses as ineligible for shared storage;
- map remaining variance to cache selection;
- test paired requests through the real caching path;
- verify proxy trust rules for forwarding metadata;
- add regression tests for important variance assumptions;
- document purge and incident procedures.
The target is not the largest possible cache key. The target is a precise equivalence relation: requests share a stored response only when that reuse preserves the application’s security semantics.
When cache identity matches response variance, a CDN remains a performance layer instead of becoming an attacker-controlled distribution mechanism.