Build · Advanced
Caching customer-specific prices: when a fast site shows the wrong price
A page cache stores one response and hands it to many visitors. If that response contains a customer-specific price or account details, the next visitor receives them too.

Photo: Derrick Coetzee, public domain (CC0)
In this guide
How a page cache serves one customer’s price to another
A cache stores a response under a cache key and returns the stored copy to every later request that produces the same key. By default the key is built from the request method and the URL: scheme, host, path and query string.
Take a B2B shop where customer A has a contract discount. A is logged in and requests a product page. The application reads the session cookie and renders the page with A’s price. A cache in front of the application stores that response under the key for the product URL. Customer B requests the same URL. The key is identical, so the cache returns the stored page, with A’s price and perhaps A’s company name, without asking the application.
The application calculated the right price and the cache did what it was configured to do. The fault is that the response depended on something the key did not contain: who was asking. Every such leak has this shape. A response varies by a dimension, such as customer, price list or currency, and the key lacks it.
- A shared cache stores responses for many users: a CDN, a reverse proxy in front of the web server, or a page cache inside the server or the application.
- A private cache belongs to one user. In practice this is the browser cache.
A personal response in a shared cache is a leak between customers. In the browser cache it stays with that user, but it can still appear after logout, for example through the back button on a computer several people use.
HTTP has one built-in protection: a response to a request that carried an Authorization header must not be reused by a shared cache unless the response explicitly allows it. Cookies have no such protection. To a cache, a session cookie means nothing. Most shops and portals keep the login in a cookie, so the safeguard has to come from your own headers and cache configuration.
Page caches built into applications and server modules often follow their own rules, such as “do not cache when a login cookie is present”. If your login uses a cookie the cache does not know, that rule never applies.
The HTTP headers that decide who may store a response
The origin tells caches what they may do through Cache-Control on the response.
- private: the response is meant for one user. A shared cache must not store it, the browser may.
- public: any cache may store the response, even one that would otherwise be cacheable only privately.
- no-store: no cache, shared or private, should store the response.
- no-cache: the response may be stored, but must be revalidated with the origin before each reuse.
- s-maxage: how many seconds the response counts as fresh in shared caches, where it overrides max-age. Browsers ignore it.
A response without any of these is not automatically safe. Caches may estimate a freshness lifetime when the origin gives none, and many proxies and CDNs are configured with rules that override the origin’s headers. A “cache everything” rule added to speed up a shop can make a correct Cache-Control: private count for nothing.
Watch Set-Cookie as well. If a shared cache stores a response that sets a session cookie, later visitors can receive that cookie. Responses that start or change a session should carry no-store.
Why Vary: Cookie is a blunt tool
The Vary response header names request headers that become part of the key. So Vary: Cookie looks like the answer: one copy per cookie value.
It is rarely a good one. The cache compares the whole Cookie header, which also carries analytics and consent cookies that differ for almost every visitor. Anonymous visitors, who could all share one copy, each get their own, and the hit rate collapses. Personal pages are still stored in a shared cache, one copy per customer. And some CDNs support Vary only for certain headers, so read the documentation of yours and test.
Session boundaries: split the page, not the cache
The dependable design separates what is the same for everybody from what is personal, and sends each part with its own policy.
- Render the page without anything personal: product data, images, descriptions, and either the list price or no price. Send it with public and s-maxage so the shared cache can serve it to everybody.
- Load the personal parts in a separate request after the page arrives: customer price, account name, basket count. Send those responses with private, no-store.
- Check the session on the server for every personal request.
If prices depend on a price list shared by many customers, the price-list identifier can be added to the cache key so that each group has its own copy. This is safe only when the identifier comes from the verified session. A plain cookie such as price_group=3 that the visitor can edit lets anybody request another group’s cached page.
A simpler boundary is also valid: bypass the shared cache for every request with a session cookie. That works when logged-in customers are a small share of traffic. For a busy B2B ordering system where nearly every visitor is logged in, the split design matters more.
Caching customer-specific prices: a policy for each page type
| Page type | Personal content | Shared cache | Cache-Control on the response |
|---|---|---|---|
| Versioned CSS, JavaScript, images | None | Yes | public, max-age=31536000, immutable |
| Content and catalogue pages without personal prices | None | Yes, purge on change | public, s-maxage set, short max-age |
| Product page shell, price loaded separately | None in the shell | Yes | public, s-maxage set, short max-age |
| Customer price or basket count request | Price, basket | No | private, no-store |
| Product page rendered with the customer’s price | Price | No | private, no-store |
| Basket, checkout, account, orders, invoices | Everything | No | no-store |
| Login, logout, any response that sets a session cookie | Session | No | no-store |
Test with two customers and an anonymous visitor
This leak does not show up when one developer tests with one account. The test needs two customers with different prices, and it must go through the same cache chain as production, not straight to the application.
- Pick a product for which customer A and customer B have different prices. Purge or wait out the cache for its URL.
- Request the page as customer A. Record the price shown, any account details and the response headers.
- Request the same URL as customer B from a separate browser profile or HTTP client. Then request it with no cookies at all.
- Compare the three responses. B must see B’s price, and the anonymous visitor must see no customer price, no account name and nobody else’s session cookie.
- Read the headers: Cache-Control, Vary, Set-Cookie and Age. An Age header on a personal response means it came from a cache. Many CDNs and proxies also add a header that reports a hit or a miss.
- Repeat with the order changed: anonymous first, then A, then B. The first requester fills the cache, so each order tests a different failure.
- Run the same sequence against the separate price and basket requests, then log out and use the back button on account pages to check the browser cache.
Put the sequence into the automated tests that run before each release, and run it again after any change to the CDN, proxy or cache settings. A performance improvement is the most likely moment for this fault to be introduced, and customer portals need the same test as shops.
Questions we hear often
Want this sorted without doing it yourself?
Tell us what you are working on. A person replies within one working day.
