skip to content

A Django events calendar view wrapped in cache_page shows one member's name and RSVP buttons to anonymous visitors; why does this happen, and how do you fix it?

level: seniorimportance: should knowfreq 45%

answer

  1. decorator runs inside the middleware
  2. Vary added too late
  3. cache what is truly public
  4. split public and member views

basics

~20 s

cache_page stores the response before SessionMiddleware adds Vary: Cookie, so the first member's personalised page is keyed by URL alone and served to everyone. Cache only the public calendar and never_cache the logged-in view, or vary explicitly inside the decorator.

solid answer

~40 s

`cache_page` wraps the view directly, so its store step runs **before** any response middleware. The view read `request.user`, which touches the session, but `SessionMiddleware` only adds `Vary: Cookie` on its way out, after the page was already stored under a key built from the URL alone. Django's docs say it plainly: `cache_page` does not automatically vary by authentication, sessions or cookies. Fixes, best first: serve the public calendar from a view that renders nothing user-specific and cache that; keep the member view uncached with `never_cache`; or, if a per-user copy is really wanted, make the view add `Vary: Cookie` itself inside the decorator. A site-wide `UpdateCacheMiddleware` placed first would see `Vary: Cookie`, and it skips responses marked `private`, `no-cache` or `no-store`.

code

python · 21 lines
python
# events/views.py
from django.shortcuts import render
from django.views.decorators.cache import cache_page, never_cache

from .models import Event


@cache_page(600)
def public_calendar(request):
    # Renders nothing user-specific: no request.user, no session.
    events = Event.objects.filter(is_public=True).order_by("starts_at")
    return render(request, "events/public_calendar.html", {"events": events})


@never_cache
def member_calendar(request):
    events = Event.objects.order_by("starts_at")
    rsvps = set(request.user.rsvps.values_list("event_id", flat=True))
    return render(
        request, "events/member_calendar.html", {"events": events, "rsvps": rsvps}
    )

go deeper

for a junior

Recall that cache_page stores one response per URL and does not know who the user is.

for a middle

Explain that the decorator runs inside the middleware stack, so Vary: Cookie from SessionMiddleware arrives after the page is stored.

for a senior

Fix it by splitting public and member views, using never_cache, and verifying headers and bodies for both kinds of visitor.

for a principal

Set a rule for which responses may be cached whole, and make personalisation an explicit, reviewed exception rather than an accident.

## The scenario An events site has one URL, `/calendar/`, served by one view. Anonymous visitors see the public list; logged-in members see the same list plus their name in the header and "RSVP" buttons. Someone adds `@cache_page(600)` to speed it up. Within minutes, anonymous visitors see "Hello, Dana" and Dana's RSVP state. ## Why the leak happens The cause is **where** the decorator runs: 1. `cache_page` is `CacheMiddleware` turned into a decorator around the view. Its fetch and store steps wrap the view function only. 2. The view renders `request.user`. Reading the user touches the session, so `request.session.accessed` becomes `True`. 3. The view returns. The decorator's store step runs **now**, before the response travels back up through `MIDDLEWARE`. 4. At this moment the response has no `Vary: Cookie`, so Django learns an empty header list for the URL and stores the page under a key built from the URL (plus language and time zone when enabled). 5. Only afterwards does `SessionMiddleware.process_response` add `Vary: Cookie`, too late for the stored key. 6. The next request for `/calendar/`, from anyone, computes the same key and gets Dana's page. Django's documentation states this directly: `cache_page` is for content that is safe to reuse for requests to the same URL and "does not automatically vary by authentication, sessions, cookies, or other request-specific state". ## Fixes, in order of preference - **Split public from personal**. Serve the anonymous calendar from a view (or URL) that renders nothing user-specific, and cache only that. Members get an uncached view, or the page loads personal bits separately. - **Mark the member view uncacheable** with `never_cache`. It adds `Cache-Control: max-age=0, no-cache, no-store, must-revalidate, private`, and both `cache_page` and `UpdateCacheMiddleware` refuse to store responses carrying `private`, `no-cache` or `no-store`. - **Vary explicitly inside the decorator** when a per-user copy is intended: the view (or a vary decorator applied beneath `cache_page`) adds `Vary: Cookie` before the store step. This keys pages per cookie value, which is safe but stores one copy per visitor, so the hit rate for members is poor. - **Use the site-wide pair in the right order** instead of the decorator: with `UpdateCacheMiddleware` first, it sees `Vary: Cookie` from `SessionMiddleware`. ## Fixes that do not work - **A shorter timeout** only narrows the window; for its duration, whoever rendered the page first still defines it for everyone. - **Switching backends** (Redis instead of local memory) changes where the leaked page is stored, not whether it leaks. With a shared backend the leak actually reaches more visitors. - **`login_required`** does not help: placed under `cache_page`, a cached hit is returned before the login check ever runs; placed above it, anonymous visitors are redirected, but two different members still share one cached copy. - **Clearing the cache on login** fixes nothing for the next visitor, who receives whichever page was stored most recently. ## Protections Django already applies Recent releases tightened the middleware; as of Django 6.1 the store step skips a response when: | Condition | Why | |---|---| | It sets a cookie and varies on `Cookie` | A session or CSRF cookie must not be replayed to others | | `Cache-Control` has `private`, `no-cache` or `no-store` (any letter case) | The view opted out | | `Vary` is `*` | No key can represent it | | Status is not `200`, or it is streaming | Only complete successful pages are stored | It also adds `Vary: Authorization` when the request carried an `Authorization` header and the response is not marked `public`. None of these catch the scenario above: Dana's page sets no cookie and says nothing about being private. ## Verifying the fix 1. Request `/calendar/` logged in, then anonymously, and compare bodies. 2. Inspect the response headers: public pages carry `max-age` and `Expires`; member pages carry `private, no-store`. 3. Add a test that renders the page as a member first and then as an anonymous client, asserting the member's name is absent.

  • Why does the same view not leak when UpdateCacheMiddleware is first in MIDDLEWARE?
    The update half then runs last in the response phase, after `SessionMiddleware` has added `Vary: Cookie`. It learns a key that includes the `Cookie` header, so each visitor's cookies map to a different entry. It is safe, but every member gets a private copy, which usually makes caching that page pointless.
  • What headers does never_cache add, and why does that stop Django's page cache?
    It adds `Expires` set to now and `Cache-Control: max-age=0, no-cache, no-store, must-revalidate, private`. `UpdateCacheMiddleware` and `cache_page` skip any response whose `Cache-Control` names `private`, `no-cache` or `no-store`, so the response is never stored.

saying these in an interview costs you the question

  • cache_page automatically keys pages by the logged-in user.
  • The leak happens only with LocMemCache, not with Redis.
  • Adding login_required under cache_page keeps members' pages apart.
  • SessionMiddleware adds Vary: Cookie before cache_page stores the page.
  • Lowering the timeout to a few seconds makes per-user caching safe.