---
key: "cache-poisoning-deception"
title: "Prevent shared-cache poisoning and private-response deception"
url: "https://softwaresecurity.com/domains/runtime/edge-defense/cache-poisoning-deception/"
api: "https://softwaresecurity.com/api/v1/articles/cache-poisoning-deception.json"
domain: "runtime"
topic: "Runtime, cloud \u0026 APIs"
type: "Guide"
identifiers: ["CWE-524","A01:2025","A02:2025","API8:2023","V14.2.2","V14.2.5","CAPEC-141"]
tags: ["Access control","Multi-tenancy","Proxies, caches and request smuggling"]
stages: ["design","operate"]
roles: ["developer","platform-sre"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Prevent shared-cache poisoning and private-response deception

Define lookup and storage policy, trace cache/origin interpretation differences and verify both public reuse and private isolation.

Canonical URL: https://softwaresecurity.com/domains/runtime/edge-defense/cache-poisoning-deception/

## In short

Treat a shared cache as a second authorization point. Key entries on the full request target plus every input that changes the response, never store or serve responses to requests that carry credentials, and send `Cache-Control: private` or `no-store` on personalized responses. Make the cache and origin interpret paths alike, so a private URL never passes for a static file. Test with two users and a cold cache, and keep a public hit as the positive control.

- Cache poisoning stores a harmful response under a key that ordinary requests share; cache deception gets a private response classified as public.
- RFC 9111 forbids a shared cache to reuse a response to a request with `Authorization` unless the response allows it, but nginx 1.30 does not apply that rule; bypass credentialed requests yourself.
- nginx keys on `$scheme$proxy_host$request_uri` by default, so the query is included; a key that drops it creates the locale collision in C1.
- Rules such as "`*.css` is public" turn path confusion into deception; PortSwigger's 2024 research reached them through delimiters and normalization differences.
- After a leak, purge every cache layer: changing the origin header does not remove entries already stored.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Personalized responses | `Cache-Control: private, no-store` | — | RFC 9111: HTTP caching semantics |
| Requests with `Authorization` or a session cookie | Bypass lookup and storage (`proxy_cache_bypass` and `proxy_no_cache` in nginx) | — | RFC 9111: HTTP caching semantics |
| Cache key | Scheme, host and the complete request target, query included | nginx default: `$scheme$proxy_host$request_uri`. | NGINX proxy cache configuration |
| Responses with `Vary: *` or `Set-Cookie` | Not stored (nginx default) | — | NGINX proxy cache configuration |
| Cache rules versus origin `Cache-Control` | Origin `private` and `no-store` must win over extension or path rules | — | PortSwigger Research: Gotta cache 'em all (2024) |

## Verify it

1. With a cold cache, request a personalized URL as user A, then as user B; B must receive its own data and the cache status must be `BYPASS` or `MISS`.
2. Request the same URL anonymously after an authenticated warm-up and confirm no personalized bytes come back.
3. Request a public URL twice and confirm `MISS` then `HIT` with one origin request, so caching still works.
4. Append `.css`, `;x.css` and `%2F..%2Fstatic` variants to a private route and confirm each is either rejected or returned with `Cache-Control: private, no-store` and never cached.
5. Run the local project with `EXERCISE_MODULE=reference node --test exercise.test.mjs` and expect 12 passing tests.

## What you will learn

- Identify response-changing inputs omitted from a cache key
- Separate cache poisoning from cache deception
- Preserve safe public hits while preventing private response reuse

Audience: Developers and platform engineers responsible for application/CDN cache behavior

Before you start:

- Understand HTTP requests and response headers
- Distinguish public content from user-specific content
- For the project: Node.js 22+ and loopback server access

Time: About 33 minutes: 8 to read and 25 for the exercise, plus 45 to 60 for the local project

## A shared hit releases data without asking the origin again

A shared cache is a second response-serving component. On a hit it may return bytes before origin authentication, authorization or application logging executes. Its security contract therefore includes audience, representation, freshness and invalidation, not just a performance key. A perfectly correct origin permission check cannot fix a private response already served from an inappropriate cache entry.

Cache poisoning makes a request produce an undesirable response that is stored under a key also used by ordinary requests. Cache deception instead causes personalized content to be classified as publicly cacheable, often because the cache and origin interpret a path differently. Both involve a mismatch, but the first question in an investigation differs: which omitted input changed the response, or why was this private response considered eligible?

## Three collisions with different repairs

For C1, the origin includes locale in the catalogue response, but the cache key drops the query string. A request for French warms `/catalog`; a later English request receives the French representation. Keeping the complete request target fixes this particular collision. It does not settle whether the response is public or whether a future header starts changing it.

For C2, Alice warms `/private` and Bob receives Alice’s invoice on a hit. Adding tenant to the key still shares data among different users inside that tenant and ignores no-store. The local project takes a simple explicit policy: authenticated or cookie-bearing requests bypass both cache lookup and storage. This is the project’s contract; HTTP itself lets a shared cache store an authenticated response when the response explicitly allows it, for example with `Cache-Control: public`.

For C3, a synthetic origin routes every `/account/...` path to a private account controller. A synthetic cache rule classifies `*.css` as public. The private response for `/account/export.css` is now a candidate for public reuse even if the attacker never knew its contents in advance. The repair must address route fallback and cache eligibility. Renaming a URL alone leaves the disagreement available elsewhere.

```text
C1: key=pathname; /catalog?locale=fr and /catalog?locale=en both become /catalog
C2: key=method+path; Alice GET /private stores Alice invoice; Bob hits same key
C3: cache marks *.css public; origin maps /account/* to current account
Project contract: only anonymous GET with no Cookie; exact public response directive; no Set-Cookie; no unsupported Vary.
C3 is a paper fixture; the local project implements C1/C2 and additional cache/header checks.
```

## Separate eligibility, identity and reuse

Write the contract route by route. Which requests can read a shared entry? Which responses can create one? Which dimensions change its representation? How long is reuse permitted, and what invalidates it? A key cannot compensate for ineligible content, and checking storage alone cannot prevent a later unauthorized lookup of an existing entry.

RFC 9111 distinguishes `private`, `no-store`, freshness and revalidation. `private` constrains shared storage; `no-store` directs caches not to store. `no-cache` requires validation before reuse rather than simply forbidding storage. `Authorization` has special shared-cache rules with explicit exceptions. Use supported cache implementations for these semantics; a homemade string check is only appropriate when the producer and consumer deliberately enforce a narrow teaching contract.

`Vary` identifies request header dimensions used to select a representation. The project does not implement that machinery and therefore bypasses any response carrying `Vary`. It also refuses `Set-Cookie` responses under its conservative contract. These explicit limitations are preferable to claiming that a small map is a general HTTP cache. New origin behavior must update the cache contract and tests together.

## Configure the shared cache: an nginx example

nginx implements much of RFC 9111, but not its rule for `Authorization`. With a plain `proxy_cache` in nginx 1.30.5, an origin response marked only `max-age=60` to a request carrying `Authorization: Bearer alice` was stored and then served to Bob and to an anonymous client. The configuration below closes that gap: any request with an `Authorization` header or a cookie bypasses both lookup and storage. In the same test Alice and Bob each received their own data, the public catalogue returned `MISS` then `HIT` with one origin request, `?locale=en` got its own entry because the key keeps the full target, and a response marked `private` was not stored.

nginx also refuses to store responses with `Set-Cookie` or `Vary: *`, and stores other `Vary` values as separate variants. None of that protects a personalized response the origin labels as public, so the origin header and the cache rule must agree.

```nginx
# Shared cache at the edge (nginx 1.30). nginx does not apply RFC 9111's Authorization rule,
# so requests that carry credentials must bypass the cache explicitly.
proxy_cache_path /var/cache/nginx/edge keys_zone=edge:10m max_size=1g inactive=10m use_temp_path=off;

map "$http_authorization$http_cookie" $credentialed {
    ""      0;
    default 1;
}

server {
    listen 8080;

    location / {
        proxy_pass http://cacheorigin:8080;
        proxy_cache edge;
        proxy_cache_key $scheme$host$request_uri;  # the full target, query included
        proxy_cache_bypass $credentialed;          # never answer a credentialed request from cache
        proxy_no_cache $credentialed;              # never store the response to one
        add_header X-Cache-Status $upstream_cache_status always;
    }
}
```

## Reproduce the leak and repair both directions

Download the proxy/cache project linked with this guide and run `node observe.mjs`. Alice receives a `MISS` with tenant acme. Bob then receives a `HIT` containing Alice’s invoice. This is a real response from the local proxy; no network mock returns the expected data for the test.

Run `node --test exercise.test.mjs`. The starter passes four cases and fails eight. In `exercise.mjs`, preserve method and the full target in `cacheKey`, bypass authenticated/cookie requests in `mayReadCache`, and require the narrow public contract in `mayStore`. Keep `validHeaders` consistent with the framing guide at /domains/runtime/edge-defense/http-request-desynchronization/. The reference command `EXERCISE_MODULE=reference node --test exercise.test.mjs` passes all twelve tests.

Keep the public-cache positive control. Two anonymous `/catalog` requests must still produce `MISS` then `HIT` with one origin invocation. A patch that disables all cache reads avoids the disclosure but does not implement the stated functional requirement. Compare body content, cache status and origin count; a response header alone does not prove whose bytes were delivered.

## Make cache and origin interpretation agree

A route that accepts arbitrary suffixes can turn a static-looking URL into a private controller. Prefer exact route matches for private features, reject unexpected suffixes, and derive eligibility from an explicit route/response contract rather than extension alone. Check encoded separators, normalization and redirects in your own stack; different intermediaries can transform a target at different stages. PortSwigger's 2024 research showed how: a semicolon that Spring treats as a delimiter, an encoded `#` or an encoded dot-segment can make a cache see `/static/x.css` while the origin serves `/account`. Its advice is to mark dynamic responses `no-store` and `private`, make sure cache rules cannot override `Cache-Control`, and avoid normalizing the cache key differently from the origin.

An origin that uses forwarded host or other client-influenced metadata to construct links can produce different responses for requests with the same cache key. Trust only headers established by a trusted proxy, as the API gateway inventory guide at /domains/runtime/api-security/api-gateway-inventory/ describes, strip client duplicates and keep canonical URL construction independent of untrusted forwarding inputs when possible. Adding every attacker-controlled header to the key can also create unbounded cache cardinality; the right repair may be to remove the input’s influence.

Cloudflare’s cache-deception protection compares extension and response type for a documented class of mismatch, with documented exceptions and override interactions. Treat it as a product-specific additional control. Confirm effective rule ordering and origin directives on the actual account; an enabled switch cannot establish that every personalized route is safe.

## Test a cold cache, a warm cache and recovery

Use isolated caches and synthetic users for an authorized test. Start empty, warm using A, request using B, then reverse the order. Exercise an anonymous request after an authenticated warm-up, cookie-bearing requests, changed query/header dimensions, redirects and errors. Keep representative successful public hits. Record origin execution so a hidden bypass is visible.

After a confirmed leak, disable or repair the unsafe reuse rule and purge affected entries at every relevant layer. Changing an origin header does not necessarily remove entries already stored downstream. Bound the exposure window using cache lifetimes and available request evidence; avoid claiming that cache purge can recall responses already delivered to clients.

For a policy rollout, compare origin load as well as correctness. A cache fix can increase traffic to a previously bypassed backend. Capacity planning is not a reason to preserve a disclosure, but it is part of deploying the repair reliably. Document the owner who must update the cache contract when a route starts returning personalized data.

## Retain these route-specific outcomes

The project deliberately whitelists one public contract. This table is its acceptance matrix, not a general-purpose interpretation of every possible `Cache-Control` value.

| Response/request | Project result | Reason |
| --- | --- | --- |
| Anonymous `/catalog`, exact public directive | Store and reuse by method + complete target | Public and representation key is complete for this route |
| Bearer token or `Cookie` on request | Bypass lookup and storage | Keep private/session traffic outside shared cache |
| `no-store` `/nonce` | New origin request each time | Response is ineligible |
| `Vary: Accept` | Bypass | This cache does not implement variant selection |
| `Set-Cookie` response | Do not store | Conservative session boundary |
| `POST /echo` with a different body | Do not reuse | The project only caches `GET` |

## Separate a useful project from production evidence

The project runs one Node origin and proxy with a bounded 30-second in-memory cache. It does not implement browser caching, a CDN, distributed invalidation, stale response rules or multi-region propagation. The deception scenario is a self-contained design review; it is not falsely presented as a feature of that executable. Finish by mapping the same cases to the actual cache product and saving observed outcomes against its effective configuration.

## Guided practice

Apply the supplied cache/origin contracts, then repair the downloadable real cache implementation. Work through the paper deception case separately from the executable routes.

### Supplied material

```text
T1: anonymous GET /catalog?locale=fr -> 200 {locale:fr}; Cache-Control: public, max-age=30.
T2: anonymous GET /catalog?locale=en -> same body from pathname-only cache.
T3: Alice GET /private -> 200 {tenant:acme,invoice:120}; Cache-Control: private, no-store.
T4: Bob GET /private -> T3 body on shared hit; origin receives no request.
T5: authenticated /account/export.css -> private account HTML at origin; synthetic edge rule ignores origin directive and marks *.css public.
T6: anonymous GET /catalog -> Vary: Accept; cache implementation has no variant support.
Safe public control: two anonymous /catalog requests with identical complete target and exact public directive.
```

### 1. Diagnose representation identity

Identify the missing dimension in T1/T2 and state the smallest repair that resolves this fixture.

Worked answer: The locale query changes the origin representation but is omitted from the cache key. Preserve the complete target including the query. This addresses T1/T2 only; it does not make private content eligible or implement `Vary`.

### 2. Find the authorization bypass

Explain why adding a permission check inside the origin does not repair T4.

Worked answer: The shared cache returns the response without contacting the origin. Eligibility and lookup policy at that cache must prevent private reuse; origin permission checks still remain necessary for misses. Purge existing private entries as part of the repair.

### 3. Distinguish deception

Explain why T5 is different from changing the content of an otherwise public catalogue.

Worked answer: The victim’s private account response is misclassified as a static public asset because routing and extension-based eligibility disagree. Enforce exact private routing and private/no-store behavior at the cache; test the effective override rules. An extension or `Content-Type` guard is only an additional layer.

### 4. Handle a feature you do not implement

Choose a result for T6 and justify why adding `Accept` only sometimes is unsafe.

Worked answer: Bypass the response until variant selection is deliberately implemented and tested. The supplied cache has no `Vary` machinery. An occasional ad hoc header key can still miss other header dimensions and the changed contract must be reviewed end to end.

### 5. Repair while retaining functionality

Run the local project and state the positive control that a blanket cache disable would fail.

Worked answer: Reference: 12/12 tests pass. Two identical anonymous public `/catalog` requests produce `MISS` then `HIT` with one origin hit. The private, cookie, `Vary`, `no-store` and `POST` cases must still reach the origin appropriately. Keep the observed leak trace and repaired result with the code change.

Deliverable: A cache route contract, T1–T6 decisions with reasons, and an executable regression record preserving useful public caching.

Scope: T5 is explicitly a paper deception fixture. The downloadable project uses loopback HTTP and validates the other named cache boundaries; test a production CDN separately in an isolated authorized environment.

## Executable practice

- [Repair a real proxy and shared cache: project ZIP](https://softwaresecurity.com/downloads/projects/proxy-cache-boundaries-66af6733aa29700d.zip) — Node.js 22+ · loopback servers · no dependencies

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [RFC 9111: HTTP caching semantics](https://www.rfc-editor.org/rfc/rfc9111.html). June 2022.
- [Cloudflare: avoid web cache poisoning](https://developers.cloudflare.com/cache/cache-security/avoid-web-poisoning/)
- [Cloudflare: cache deception protection and exceptions](https://developers.cloudflare.com/cache/cache-security/cache-deception-armor/)
- [NGINX proxy cache configuration](https://nginx.org/en/docs/http/ngx_http_proxy_module.html)
- [RFC 9111 §3.5: storing responses to authenticated requests](https://www.rfc-editor.org/rfc/rfc9111.html#section-3.5). RFC 9111 (June 2022).
- [PortSwigger Research: Gotta cache 'em all (2024)](https://portswigger.net/research/gotta-cache-em-all)
- [PortSwigger Web Security Academy: web cache deception](https://portswigger.net/web-security/web-cache-deception)