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.

7 min read

An aisle between rows of server racks in a data centre

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.

Shared caches and the private browser cache

  • 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.

  1. 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.
  2. 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.
  3. 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 types and the caching policy that fits them
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.

  1. Pick a product for which customer A and customer B have different prices. Purge or wait out the cache for its URL.
  2. Request the page as customer A. Record the price shown, any account details and the response headers.
  3. Request the same URL as customer B from a separate browser profile or HTTP client. Then request it with no cookies at all.
  4. 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.
  5. 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.
  6. Repeat with the order changed: anonymous first, then A, then B. The first requester fills the cache, so each order tests a different failure.
  7. 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

It keeps correctly behaving shared caches from storing the page, but the browser may still keep it. For prices and account data, private, no-store is the safer choice, and you still need to confirm that no CDN or proxy rule overrides the header.

Yes. Cache the parts that are the same for everybody in the shared cache and load the personal parts separately. Static files and the page shell then come from the cache, and only small personal requests reach the application.

Yes. A reverse proxy in front of the web server, a page cache module in the server or a caching plugin in the application are all shared caches and can serve one customer’s page to another.

Want this sorted without doing it yourself?

Tell us what you are working on. A person replies within one working day.