← Blog

Drupal

Drupal 9 cache poisoning: when a CDN purge leaks admin

At 18:47 on a Tuesday a client pinged us: their Drupal 9 homepage was rendering edit links to the entire internet. Here is what nine hours of digging found.

Jacob Molkenboer· Founder · A Brand New Company· 5 Jun 2026· 9 min
Worn leather Drupal logbook ajar on ivory paper, brass key on index card, green ribbon, broken red wax seal.

It was 18:47 on a Tuesday when the client's WhatsApp lit up. Three messages in a row, all variants of the same screenshot: their Drupal 9 homepage rendering an "Edit" link next to every product tile, an "Operations" dropdown in the header, and a contributor sidebar listing the names of three internal editors. Served to the entire internet.

The next nine hours, two of us sat in a Meet call with the client's lead developer, working backwards from a cached response to a race condition to a one-line preprocess hook that had been quietly leaking admin context for nearly two years.

The first ten minutes

The reproduction was instant. Incognito window, fresh IP via tether, no cookies: same admin-furnished homepage. So it was not a session leak. It was a cache leak.

The response headers told the rest:

HTTP/2 200
x-cache: HIT
x-served-by: cache-ams21034-AMS
x-cache-hits: 847
x-drupal-cache: HIT
age: 1162

847 hits in under twenty minutes. Fastly was happily serving a logged-in admin's render to everyone who landed on the homepage.

First instinct: purge Fastly. Done. Refresh. Same page came back within four seconds. Whatever was poisoning it was upstream and self-healing.

Where the obvious fix failed

We ran the standard ladder. drush cr to flush Drupal's caches. TRUNCATE on the cache_render and cache_dynamic_page_cache tables. A Fastly purge by surrogate key. Then a full Fastly purge-all.

Each time, the bad response came back within seconds. Sometimes within milliseconds. The render cache was being rewritten with the same poisoned HTML almost immediately after every flush.

The pattern only makes sense if you accept that someone or something was making a request as an authenticated admin in the window between the flush and the next anonymous hit, and that the response from that admin request was being written into a cache that anonymous users would then read from.

Warning

If a render cache lookup ever returns content built under a different user's permissions, you do not have a caching bug. You have a permissions bug that the cache made worldwide.

Tracing the poisoned response

We added a debug header to the response so we could see which cache tags and contexts the rendered page was carrying. Drupal makes this easy if you enable the dev services:

# sites/default/services.yml
parameters:
  http.response.debug_cacheability_headers: true

The next bad response told us everything:

x-drupal-cache-tags: block_view config:block.block.homepage_curated
  node:1247 node:1289 node:1301 user:42 user_view
x-drupal-cache-contexts: languages:language_interface url.path

Two things stood out. The response was carrying user:42 as a cache tag, which meant a user entity had been touched during the render. And the cache contexts list named only languages:language_interface and url.path. No user.roles. No user.permissions. No user.

In Drupal's cache model, that combination is a confession. The response depends on a specific user, but it is being keyed as if it depends on nothing about the user. So the first render wins, and every subsequent request with a matching URL and language reads it back.

The block at the centre of the bug

The cache tag user:42 led us to a custom block placed inside a Layout Builder section on the homepage. The block was a Views display, "Curated products", configured with a "Global: Custom text" header area. The header was the smoking gun:

{# views-view--curated-products.html.twig #}
{% if user.hasPermission('administer nodes') %}
  <a href="{{ path('entity.node.edit_form', {node: row.nid}) }}">Edit</a>
{% endif %}

The template is doing the right kind of check. The problem is what is missing one layer up. The block plugin's getCacheContexts() never declared that the render depended on the current user's roles or permissions:

// modules/custom/abn_homepage/src/Plugin/Block/CuratedProductsBlock.php
public function getCacheContexts() {
  return Cache::mergeContexts(
    parent::getCacheContexts(),
    ['url.path', 'languages:language_interface']
  );
}

That is the entire bug. The block conditionally renders an admin-only link, but tells the cache system "store this once per URL, regardless of who is viewing it". Drupal does what you ask. The render with the Edit link gets stored. The next anonymous visitor gets it back.

The race that wrote the cache

The piece we still needed to understand was timing. Why now? The block had shipped almost two years ago. We pulled the deploy log.

2026-06-02 18:00:03  deploy.sh start
2026-06-02 18:00:11  drush updb -y
2026-06-02 18:00:14  drush cr
2026-06-02 18:00:15  fastly purge --all
2026-06-02 18:00:17  deploy.sh ok

At 18:00 the deploy script purged Fastly as part of the release. The very next request to the homepage came at 18:00:19, from an editor who had stayed logged in to verify the new hero image had landed. Their session-warmed render hit a cold Drupal cache, populated it, and Fastly's edge cached the upstream response under the homepage's URL.

For the previous two years the homepage was almost never the first hit after a purge. Editors usually verified deploys by deep-linking to product pages. This time the editor went to the homepage. The race was won by the worst possible candidate.

The first cache miss after a purge is a privileged moment. Whoever wins that race writes the answer for everyone else.

Cache contexts are a contract

The Drupal Cache API is one of the better-designed pieces of the framework, and it is explicit about this exact failure mode. The official cache contexts documentation spells out the rule plainly: anything a render varies by has to be declared, or the cache will reuse it in situations where it should not.

The intent is that every render array, block, and controller response declares the full set of inputs its output depends on. Roles. Permissions. Query string. Theme. Language. Anything that changes the bytes you ship.

The contract is enforced at exactly one place: the developer's keyboard. There is no static check that confirms a template's {% if user.hasPermission(...) %} block is matched by a corresponding user.permissions entry in getCacheContexts(). The render system trusts what you tell it.

This is the class of bug that escapes most code review and most automated scanners, because the symptom only appears when the cache, the CDN, and a privileged user line up in the wrong order. None of those pieces is wrong in isolation.

The fix at the application layer

The patch was three lines. Add the two missing contexts, and force a re-render for users whose permissions might change the output:

public function getCacheContexts() {
  return Cache::mergeContexts(
    parent::getCacheContexts(),
    ['url.path', 'languages:language_interface',
     'user.roles', 'user.permissions']
  );
}

With user.permissions in the context list, Drupal varies the render cache key by a hash of the viewer's permissions. Anonymous users share one cache entry. Editors share another. Admins share a third. No one reads anyone else's bytes.

We also moved the admin link rendering into a #lazy_builder. Lazy builders are placeholders that Drupal evaluates per request, after the main render cache lookup. The expensive cached shell can be reused across all viewers, while the small per-user fragment is rebuilt every time. This is what BigPipe was designed for.

$build['edit_link'] = [
  '#lazy_builder' => [
    'abn_homepage.lazy_builders:editLink',
    [$node->id()],
  ],
  '#create_placeholder' => TRUE,
];

The fix at the edge

The application fix closes the bug. The edge fix makes sure a future variant of it cannot reach 847 hits before anyone notices.

We changed two things in Fastly's VCL. First, never cache a response that carries a session cookie:

sub vcl_fetch {
  if (beresp.http.Set-Cookie ~ "^S?SESS[a-f0-9]+=") {
    set beresp.uncacheable = true;
    set beresp.ttl = 0s;
    return (deliver);
  }
}

Second, strip session cookies from requests that should be anonymous, so they cannot upgrade themselves to authenticated by accident:

sub vcl_recv {
  if (req.url !~ "^/(user|admin|node/\d+/edit)") {
    unset req.http.Cookie;
  }
}

The second rule is aggressive and we only ship it on sites where the anonymous and authenticated experiences are cleanly separated. On this client's site they were, so it shipped.

What we now check on every Drupal site we run

The post-mortem produced four rules we now enforce on every Drupal site under our care, contrib or custom.

One. Every block plugin, controller, and render array that conditionally renders content based on the viewing user must declare user.permissions in its cache contexts. We grep for hasPermission in custom modules and templates and audit the matching cache metadata.

Two. The first request after a deploy is made by a script, not a human. The script hits the homepage and three deep product pages as an anonymous user via a clean curl, warming the caches with a guaranteed-correct render. Editors verify after that.

Three. Production deploys never run while admin sessions are live. The deploy script kicks active sessions older than five minutes before purging the CDN.

Four. Fastly never caches a response with a session cookie. This is the belt that catches anything the application's suspenders miss.

The blind spot in static analysis

Most of the static analysis tooling for Drupal, including the excellent phpstan-drupal, does not catch cache context omissions. The relationship between a Twig permission check and a PHP cache context list crosses two languages and one runtime boundary. Recent work on AI-assisted code review is starting to reach this kind of cross-file invariant, but in our experience it still misses the integration angle where the bug only manifests under a specific CDN configuration. This one needed a human reading response headers.

Where to start tomorrow morning

If you run Drupal behind a CDN, the cheapest five-minute audit you can do is this: open your homepage in incognito, then in a window where you are logged in as an admin, and compare the rendered HTML. Diff them. If you see admin-only strings, attributes, or links inside fragments that the cache thinks are anonymous, you have a candidate for the same bug we spent nine hours on.

When we rebuilt the homepage block stack for this client, the thing we kept running into was that small custom blocks accumulate cache assumptions that nobody documents. We ended up writing a thin auditor that walks every block plugin and flags any that read from \Drupal::currentUser() without declaring the matching cache contexts. That kind of legacy Drupal work is most of what comes through our door these days.

Key takeaway

Cache contexts are a contract you sign with Drupal, and the CDN amplifies every breach you forget to declare.

FAQ

Why did the Drupal render cache serve an admin page to anonymous users?

The block that rendered admin Edit links did not declare user.permissions in its cache contexts. Drupal stored the admin render once and replayed it to every anonymous visitor at the same URL.

What is a Drupal cache context?

A cache context is a declared input that the rendered output depends on, such as user.roles, url.query_args, or theme. Drupal varies the cache key by every context you list, and trusts you to list them all.

How do you prevent CDN cache poisoning in Drupal?

Declare every cache context your render depends on, move per-user fragments into lazy builders, and configure the CDN to refuse caching any response that carries a session cookie.

Can phpstan-drupal catch missing cache contexts?

Not today. The relationship between a Twig permission check and a PHP getCacheContexts list crosses two languages and a runtime boundary, which is outside the rules phpstan-drupal currently ships.

drupalphpsecurityarchitectureoperationscase study

Building something?

Start a project