Paying subscribers are hitting the wall, and only on the popular stories
In short
A response stored without an access dimension in its cache key is served to whoever asks next, so a subscriber's chance of getting the anonymous variant is roughly the object's cache hit ratio. That is why the complaint concentrates on the busiest stories and is invisible on quiet ones. The repair is a coarse access class in the key plus a client-side reveal.
Key takeaways
- Popularity is exposure, not cause: a subscriber's odds of the wrong variant approximate the object's hit ratio.
- The same missing cache dimension leaks the other way, storing a subscriber's page and serving it to anonymous readers.
- Vary on the raw Cookie header is not a fix — consent and analytics cookies make the variant space effectively per-browser.
- Two or three access classes in the key keeps the hit ratio; a per-reader key abandons caching entirely.
- RFC 9111 says a stored response whose Vary is * never matches, so that header disables caching rather than tightening it.
The cause is almost always that the HTML was stored in a shared cache without anything in the cache key that says who it was rendered for. Once that has happened the object is served to whoever asks next, subscriber or not, and the probability that a given subscriber gets the anonymous copy is roughly the object's cache hit ratio. A story running at a 0.98 hit ratio will show the wall to almost every subscriber who opens it; the same code on a quiet archive page looks perfect, because that page is nearly always rendered fresh. Popularity is the exposure, not the fault.
If the disagreement is between surfaces rather than between requests — the app is happy and the website is not — that is a different investigation with different evidence, traced in the app says subscribed, the site says not. If access is wrong everywhere for one reader, look at whether the grant was ever updated at all: renewals that never reached the access system.
One hot URL, two locations, five minutes
- Pick a story currently trending and request it as an authenticated subscriber, with the real session cookie, from two networks far enough apart to land on different edge locations.
- Record the cache status header your CDN emits, the age header, and every Vary and Set-Cookie on the response. Repeat each request 5 times, because the first is often a miss and looks fine.
- Request the same URL anonymously and diff the two bodies. If they are byte-identical, one of them is wrong, and you now know which population is being served the other's page.
- Cross-check against traffic. Pull the top 50 URLs by request volume and the subscriber complaint counts for the same window. A correlation with hit ratio confirms the diagnosis; a flat distribution across popular and unpopular stories points somewhere else entirely.
- Check the reverse direction before closing the ticket. Request a cold, unpopular URL as a subscriber, then immediately request it anonymously. If the subscriber's full-text page comes back, the cache is also leaking paid content outwards, and that is the more expensive half of the same bug.
Five ways an anonymous variant reaches a paying reader
| Cause | What you will see | The check |
|---|---|---|
| No access dimension in the cache key | Byte-identical bodies for subscriber and anonymous requests, high age values | Diff the two responses on a hot URL; inspect the key configuration for anything reader-derived |
| The auth cookie is stripped or ignored at the edge | Origin logs show the request arriving without the session cookie | Compare the Cookie header the browser sent against what the origin received |
| A client-side gate racing the render | Correct after a refresh; the wall flashes and then disappears, or does not | Throttle the network in dev tools and watch the order of the access call and the paint |
| A cached access decision outliving the entitlement change | Wrong for a bounded period after a renewal or upgrade, then self-corrects | Read the TTL on the decision cache and compare it against the complaint window |
| Vary set so broadly it destroyed the hit ratio | No wrong pages, but origin load and latency climbed after the last fix | Count distinct variants stored per URL; check whether Vary names Cookie or is set to * |
The last row is what the previous attempt at this bug usually left behind. It is worth separating clearly, because a team that ships it thinks the problem is solved and is instead measuring a different failure: the origin is now rendering everything.
Vary on Cookie is the repair that removes the cache instead
The Vary header tells a cache which request headers form part of the secondary key: RFC 9111 requires a stored response not to be reused unless every request header named by Vary matches the original request's. Naming Cookie there is technically correct and practically ruinous, because the cookie header carries consent state, analytics identifiers and experiment buckets. Every distinct cookie string becomes its own stored variant, so the variant space approaches one entry per browser and the hit ratio approaches zero.
- Vary: * is worse, not stricter. The specification is explicit that a stored response with a Vary of * never matches, which makes the response effectively uncacheable — MDN documents it in exactly those terms.
- Authorization headers have their own rule. RFC 9111 forbids a shared cache from reusing a response to a request carrying Authorization unless the response explicitly permits it with a directive such as public, s-maxage or must-revalidate. Paywalls driven by bearer tokens hit this and read it as a CDN bug.
- Cache-Control: private is not access control. It stops shared caches storing the response, which protects the reader's browser cache but throws away every edge benefit — and it does nothing about who the page was rendered for.
- Normalise before you vary. Compute one derived header at the edge — an access class such as anonymous, registered or subscriber — from the session cookie, strip everything else from the key, and vary on that instead. Two or three variants per URL is a cache; ten thousand is a proxy.
A cache key with a reader in it is not a cache. It is an origin with extra hops and a monthly bill.
Cache a coarse class, then reveal the rest in the browser
The working shape has two halves. The edge serves a small number of cacheable variants keyed on a coarse access class, and the page completes itself in the browser with one call to an access endpoint. That endpoint answers for a specific reader, is never cached at the edge, and returns fast because it does nothing but resolve a grant — the design set out in one access check every surface can call.
- Decide the classes. Anonymous, registered and subscriber covers most estates. Institutional access adds a fourth only if its entitlements genuinely differ in what is rendered.
- Derive the class at the edge from the session cookie or token, write it into a single derived header, and make that header part of the key. Nothing else reader-derived belongs there.
- Render the gated variant with the full markup structure but without the paid body text. Never ship the full article inside hidden markup — a wall implemented in CSS is not a wall.
- Reveal in the browser after the access call returns, and keep its response short-lived so an entitlement change is visible within seconds rather than at the next TTL boundary.
- Add a standing synthetic check: an authenticated request to the current top stories from several regions, every few minutes, asserting that the subscriber body differs from the anonymous body. That is the monitor that would have caught this in the hour it shipped, and it is cheap automation of the kind we build under AI agents and automation.
What a correct cache key will still not fix
- A grant that was never updated. If the renewal event never reached the entitlement store, the access endpoint will answer no with total confidence, and the cache is blameless.
- Metered exemptions. Which sections and story ages are free is a counting policy that has to be evaluated somewhere, and folding it into the edge key multiplies your variants — the decisions set out in where the meter count lives and what it counts.
- Content that must come down now. Access classes do not help you evict a story from every location under a legal clock; that needs targeted invalidation and a measured completion time, which is the machinery behind takedown intake, triage and the visible clock.
- Stale article bodies. A subscriber seeing the right variant of an outdated story is a separate caching failure, traced in the story is updated but some readers see the old one.
This is one of the harder pieces of a publishing estate to hand to somebody else, because it fails only under real traffic and looks perfect in staging — worth weighing against the evaluation criteria in how to choose an AI development partner if you are bringing it in from outside. The rest of this silo sits under paywalls, subscriptions and entitlements, inside our media and publishing practice.
Frequently asked questions
Short answers to the follow-ups this page tends to raise.
Why do only popular articles show the paywall to logged-in subscribers?
Because popularity raises the cache hit ratio, and the hit ratio is the probability that a stored response is served instead of a fresh render. If the stored response has no access dimension in its key, a subscriber gets whatever variant was cached first, and on a busy story that is nearly every request. On a quiet story most requests miss the cache and get correctly rendered, so the bug hides.
Should I add Vary: Cookie to fix a paywall on a CDN?
No. Cookie headers carry consent, analytics and experiment values that differ per browser, so varying on them creates roughly one stored variant per reader and the cache stops working. Derive a coarse access class at the edge, put that single value in the key, and leave the raw cookie out of it.
Is it safe to render the full article and hide it behind the paywall in CSS?
No. Anything present in the delivered HTML is readable by anyone who opens the page source or disables styles, and it is also visible to scrapers. Render the gated variant without the paid body text and fetch the remainder after the access check returns.
How long should a cached access decision live?
Seconds rather than minutes, because a reader notices immediately when access is wrong and a renewal has to take effect while they are still in the payment flow. Keep the decision cache short and per-reader in their own session, and keep the shared edge cache to coarse classes, so no shared entry ever encodes an individual's entitlement.
- paywall
- cdn
- caching
- entitlements
The work behind this page
Builds from our portfolio that this page draws on.
Churn Radar
An AI customer-success platform that flags at-risk B2B accounts before they churn and prescribes the save-play to run.
Customer SuccessAskVault
An AI internal knowledge-search platform that answers employee questions from your own docs — grounded in citations, with knowledge gaps surfaced and deflection tracked.
Productivity AIRead next
- The app says subscribed, the site says notCross-surface disagreement is three causes wearing one complaint: two identities, two authorities, or two caches. Resolving the identifier on each surface eliminates the first in minutes.diagnostic
- Renewals that never reached the access systemCount the gap between what the provider sent and what you processed. Its size and shape separate delivery loss from processing loss, and only one of them is fixed by a better handler.diagnostic
- The meter count: where it lives, what it counts and who can reset itA meter is not a number. It is 4 decisions — countable event, storage location, reset window and exemptions — and most implementations make only the first one consciously.definition
- Surrogate keys: purging one story without flushing the front pageOne story lives on six surfaces. The keys attached to those responses at render time decide whether a correction reaches all six, or whether somebody asks for a full flush at the worst moment.definition
- Dunning as a state machine: retries, grace and when access stopsDunning is not a sequence of emails. It is a state machine, and every state must be written into the entitlement record so access, messaging and reporting cannot drift.definition
- The meter resets for anyone who opens a private windowA meter stored in the browser can be cleared by the browser. Measure the leak first, then decide whether identity-bound counting is worth what it costs.diagnostic
Working on something in this space?
Tell us where you are in a sentence or two. We'll tell you honestly whether we're the right team, and what a sensible first slice of the work looks like.
Start the conversation