open atlas
↑ Back to track
Next.js, zero to senior NEXT · 10 · 01

Cache poisoning incidents: when one user becomes everyone

A cache key that omits something the response depends on serves one user to everyone: an unstable_cache closure, a CDN storing Set-Cookie, a missing Vary, X-Forwarded-Host in cached HTML. The invariant: everything the response reads is in the key, or the route is dynamic.

NEXT Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

Black Friday, 09:40. Support posts a screenshot: “customer says the account page shows someone else’s saved card and address.” Three tickets by 09:50, forty by 10:05. The week before, a perf engineer had wrapped the account summary in unstable_cache(['account-summary'], { revalidate: 600 }) to kill a hot query — the user id came from a closure, not an argument, so the cache key never changed. The first user after every expiry became the template the next ten minutes of users received. Detection took 38 minutes and came from tickets, not monitoring; the blast radius line in the postmortem read “every logged-in user between 09:12 and 10:18”; legal spent the afternoon on breach-notification thresholds. The purge took seconds. The fix was one line. The expensive part was the discovery that nobody on the team had ever reviewed a cache key.

One invariant, four caches

In ten minutes you will know exactly how to spot a poisoned cache key before it ships — and what to do the moment a ticket says “I see someone else’s data.” A cache is a map from key to response. The correctness invariant is one sentence: everything the response depends on must be part of the key — otherwise two requests that deserve different responses collide in one slot, and whoever renders first becomes everyone’s response. That is the entire failure class. “Poisoning” sounds adversarial, but most production incidents are self-inflicted: the attacker is your own code reading something the key does not know about.

Next.js gives you four caches with four different keys, and the invariant must hold in each. The Full Route Cache keys on the route path: a static route renders once and the HTML plus RSC payload serve everyone. The Data Cache keys each fetch on URL and options. unstable_cache keys on keyParts plus the serialized arguments of the wrapped function — and this is the trap from the Hook: values captured in a closure are invisible to the key. The CDN keys on URL plus whatever Vary names. Four layers, four chances to omit something.

// THE BUG: userId lives in the closure — the key is constant
const getSummary = unstable_cache(
  async () => db.account.summary(session.userId), // closure capture
  ['account-summary'],
  { revalidate: 600 },
);

// THE FIX: arguments are serialized into the key
const getSummary = unstable_cache(
  async (userId: string) => db.account.summary(userId),
  ['account-summary'],
  { revalidate: 600 },
);
await getSummary(session.userId); // per-user slot — or better: do not cache this at all

Next.js has a tripwire for the route-level version of this: calling cookies() or headers() during render opts the whole route out of the Full Route Cache — personalized routes go dynamic automatically. The leak happens when personalization enters around the tripwire. A server component fetches the user via a cached data-layer call (the closure bug above, or a fetch authorized by a module-level token) and passes user.name and cart.items as props into a client component. No cookies() call ever ran, so the route stayed static — and those props are serialized into the RSC payload, which is the Full Route Cache entry. User A’s cart ships inside the cached payload to user B. The client component is innocent; the poison was baked one layer below, where the tripwire cannot see it.

Quiz

A static product page renders a client component with a cart badge. The cart comes from a data-layer function wrapped in unstable_cache(['cart'], { revalidate: 300 }) that reads the session from a closure. Users report seeing a stranger's cart count. Why did the route not go dynamic and protect them?

The second Next.js-specific shape needs no application bug at all — only an ops change. A response that carries Set-Cookie is a session being handed to one person; a shared cache that stores it hands that session to everyone who hits the entry. CDNs refuse to cache Set-Cookie responses by default, which is why this incident almost always starts with a human overriding defaults: a “cache everything, ignore origin headers” page rule added at 13:40 during a traffic emergency, and at 14:02 the first “I am logged in as someone else” ticket. That is full session bleed — the victim does not see your data, they become you. The guard is mechanical: an edge rule that never caches a response carrying Set-Cookie, regardless of what other rules say, plus Cache-Control: private, no-store on everything personalized so the origin states its intent.

Third shape: Vary discipline. If the app negotiates anything by request header — locale from Accept-Language, an A/B bucket from a header set by an upstream router — then the header is part of what the response depends on, so it must be part of the CDN key via Vary: Accept-Language. Omit it and the first-cached variant serves all: German homepage for everyone, or worse, treatment-group pricing cached for the control group. The honest tradeoff: Vary multiplies cache cardinality, and Vary: Cookie effectively disables caching. When cardinality matters, move the variant into the URL (/de/…) where the key sees it natively.

Fourth: draft mode. draftMode() exists precisely to bypass caches so editors see unpublished content — the bypass cookie is the point. The leak shape is a preview URL with the bypass token shared into a channel where a caching proxy or a prefetcher sits, or a custom preview route that forgets no-store on its responses: unpublished content cached under a public URL. Treat preview tokens like credentials, because that is what they are.

Quiz

Your app picks locale from Accept-Language in a server component and serves through a CDN keyed on URL only. Monday a German user reports the whole site is in French. What happened, and what is the correct class of fix?

The classics still work, and how to run the postmortem

When you review a PR that adds a new CDN rule or tweaks response headers, ask one question before approving: “does this let an unkeyed input reach a shared response?” The pre-framework cache-poisoning literature applies to Next.js apps unchanged, because the CDN in front speaks plain HTTP. The canonical probe is the unkeyed header: find a header that influences the response but not the cache key. X-Forwarded-Host is the classic — frameworks and SEO helpers use it to build absolute URLs for canonical links, og:image, and password-reset emails. An attacker sends one request with X-Forwarded-Host: evil.example, the response is cached under the normal URL, and every user for the next TTL receives HTML whose absolute links point at the attacker’s host. The PortSwigger playbook is to probe with a cache-buster query param, confirm reflection, then drop the buster; your security tests should run exactly that probe against your own stack.

When the incident lands, the postmortem walk is always the same four stations. Detection: “I see someone else’s cart” tickets are a P1 security incident, not a UI bug — route them to the on-call, and add the synthetic check you wish you had: two sessions request the same URL every minute and assert the responses do not bleed. Blast radius: everyone who hit the poisoned entry until purge or TTL expiry — multiply by the number of poisoned keys, and remember each expiry re-poisons with a fresh victim. Mitigation: purge every layer — CDN purge, revalidatePath/revalidateTag for the route and data caches, and a restart if anything was cached in process memory — then ship the key fix, because a purge without the fix buys you one TTL of calm. Prevention is a checklist, not vigilance: every cached response reviewed against “what does this read, and is all of it in the key”; no-store on personalized fetches; Set-Cookie never cacheable; a Vary audit on every header the app negotiates by; the two-session bleed probe and the unkeyed-header probe wired into CI.

Recall before you leave
  1. 01
    Why does reading the session inside an unstable_cache closure poison the cache, while passing the user id as an argument does not?
  2. 02
    Walk the four postmortem stations for a cache-poisoning incident and name the prevention artifacts.
Recap

Cache poisoning is one invariant violated in any of four places: everything the response depends on must be in the cache key, or the route must render dynamically. Next.js keys the Full Route Cache on route path, the Data Cache on fetch URL plus options, unstable_cache on keyParts plus serialized arguments — which makes closure-captured session data the classic self-inflicted poison, one constant key serving the first user’s data to everyone for a revalidate window at a time — and the CDN on URL plus whatever Vary declares. The dynamic tripwire only fires on cookies() and headers() during render, so personalization that enters through a cached data layer bypasses it and gets baked into the shared RSC payload. The ops-side shapes need no app bug: a CDN rule that ignores origin headers and stores a Set-Cookie response hands one user’s session to the world; a missing Vary on Accept-Language or an A/B header serves the first-cached variant to all; a leaked draft-mode token caches unpublished content publicly. The classics still apply through any CDN — an unkeyed X-Forwarded-Host reflected into canonical links or reset URLs poisons cached HTML for a full TTL. Run the postmortem in four stations: detect (cross-user tickets are P1 security; add the two-session synthetic), size the blast radius (everyone on the entry until purge, re-poisoned each expiry), mitigate (purge every layer, then fix the key — purge alone buys one TTL), prevent (key-review checklist, no-store on personalized fetches, never cache Set-Cookie, Vary audit, poisoning probes in CI). Now when you see a ticket that says “I see someone else’s data,” you know the postmortem walk before the incident room opens — and you know that purging without fixing the key just buys one more TTL of silence.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.