skip to content

What do the HTTP directives `Cache-Control: private`, `Cache-Control: public` and `Cache-Control: s-maxage` control, and what goes wrong if a per-user response is served without `private`?

level: middleimportance: must knowfreq 62%

answer

  1. private = browser only; shared caches keep out
  2. public unlocks caching for Authorization requests
  3. s-maxage = shared only, beats max-age and Expires
  4. max-age=60, s-maxage=86400 → short browser, long CDN
  5. missing private + CDN = one user's data for everyone

basics

~20 s

private restricts storage to a single-user cache (the browser); shared caches such as CDNs and proxies must not store it. public allows shared storage even when rules would normally forbid it. s-maxage sets a freshness lifetime that only shared caches use, overriding max-age. Omitting private on per-user content lets a CDN serve one user's data to everyone.

solid answer

~60 s

They split the cache population into **private** (one user's browser) and **shared** (proxies, CDNs, reverse proxies). - **`private`** — only a single-user cache may store it. This is the safety directive for anything personalised: a dashboard, a cart, a response containing a user's name. - **`public`** — explicitly permits shared storage even in cases where a cache would otherwise refuse, most importantly when the request carried an `Authorization` header, or when the status code is not cacheable by default. On an ordinary anonymous GET it adds nothing. - **`s-maxage=N`** — freshness lifetime for shared caches only; it overrides both `max-age` and `Expires` there and is ignored by browsers. It also carries `proxy-revalidate` semantics. This is how you keep a long CDN TTL with a short browser TTL, e.g. `max-age=60, s-maxage=86400`. The failure mode without `private` is severe: a CDN stores one user's personalised response under a URL-based cache key and serves it to every subsequent visitor. That is a cross-user data leak, and it is one of the most common real caching incidents.

code

http · 8 lines
http
HTTP/1.1 200 OK
Cache-Control: private, no-store
Content-Type: application/json

HTTP/1.1 200 OK
Cache-Control: public, max-age=60, s-maxage=86400
ETag: "v42"
Content-Type: application/json

go deeper

for a junior

Know that private keeps a response out of shared caches and that personalised responses need it.

for a middle

Explain all three directives precisely, including that s-maxage is shared-only and overrides max-age there.

for a senior

Talk through the cross-user leak incident, why public on authenticated routes is dangerous, and the purgeable-edge / unpurgeable-browser asymmetry that drives the TTL split.

for a principal

Set org-wide defaults — deny-by-default shared caching for authenticated routes, explicit opt-in per response class — so one missing header cannot become a data breach.

## Two kinds of cache RFC 9111 distinguishes a **private cache**, dedicated to a single user (the browser's HTTP cache), from a **shared cache**, which serves many users (forward proxies, reverse proxies, CDN edge nodes, some service meshes). Almost every directive in this family exists to tell those two populations different things, because reusing a response across users is safe for a logo and catastrophic for an account page. ## private `Cache-Control: private` means: a single-user cache may store this; a shared cache must not. Note what it is *not* — it is not encryption, not authorization, and not a promise that intermediaries cannot see the bytes. It is an instruction that cooperating shared caches must not retain the response for reuse. Any response whose body varies by identity needs it: user dashboards, shopping carts, per-account API responses, anything containing a name, balance or entitlement. The usual pairing is `Cache-Control: private, no-store` for genuinely sensitive content, or `private, max-age=0, must-revalidate` for personalised pages you still want the browser to hold for back-navigation. ## public `public` is narrower than its name suggests. On a plain anonymous `GET /logo.png` with a `max-age`, a shared cache is already allowed to store the response; adding `public` changes nothing. It matters in two situations: 1. **Authenticated requests.** By default a shared cache must not store a response to a request carrying an `Authorization` header. `public` (or `s-maxage`, or `must-revalidate`) overrides that. This is a genuinely dangerous knob: it is how a licensed asset served behind auth ends up served to anonymous users by a CDN. Use it only when the response is identical for every authenticated caller. 2. **Status codes not cacheable by default.** For statuses without defined cacheability, `public` marks the response as storable. Because `public` is easy to sprinkle on reflexively, treat it as an explicit assertion: "this body is identical for every requester of this URL". ## s-maxage `s-maxage=N` sets freshness lifetime **in shared caches only**. In those caches it overrides `max-age` and `Expires`; browsers ignore it entirely and fall back to `max-age`/`Expires`. It also incorporates `proxy-revalidate` semantics, so a shared cache must not serve the response stale after it expires. This split is how modern edge caching is configured. `Cache-Control: public, max-age=60, s-maxage=86400` means: browsers hold it a minute; the CDN holds it a day and absorbs the traffic; a purge at the edge propagates within a minute for everyone. Inverting the two (short at the edge, long in the browser) is usually a mistake, because you can purge a CDN but you cannot purge a browser cache — once a long `max-age` is out, it is out until it expires. ## The cross-user leak The canonical incident: a service returns `Cache-Control: max-age=300` on `/api/me`, whose body differs per user. A CDN's default cache key is method plus URL, so it stores the first response it sees and serves that user's profile to everyone else for five minutes. Nothing in the protocol prevents this — the origin declared the response cacheable and did not restrict it to private caches. The fixes are `private` on anything personalised, and defence in depth at the edge (deny-by-default caching for authenticated routes) so a single missing header is not a breach. The mirror-image incident is over-restricting: marking everything `private, no-store` because it is safe. That pushes every request to origin, so your CDN offloads nothing and a traffic spike lands on your servers. ## A practical decision procedure Ask one question per response: *does the body depend on who is asking?* If yes — `private`, and treat any shared caching as an error. If no, decide the shared lifetime with `s-maxage`, the browser lifetime with `max-age`, and add `public` only if the request is authenticated or the status is unusual. Keep the decision at the response class level, not per endpoint, so it is auditable.

  • Does `private` provide any confidentiality guarantee?
    No. It is an instruction to cooperating shared caches not to store the response for reuse; it does not encrypt anything and does not stop an intermediary from reading the bytes. Confidentiality comes from TLS, and access control comes from authorization on the origin. Treat `private` as a correctness directive that prevents cross-user reuse, not as a security boundary.
  • Why does a shared cache refuse by default to store a response to a request that carried an Authorization header?
    Because the presence of credentials is a strong signal the response is specific to that caller, and reusing it for another user would leak data. RFC 9111 therefore makes it non-storable in shared caches unless the response explicitly opts in with `public`, `s-maxage` or `must-revalidate`. That opt-in should only be used when the body genuinely does not vary by caller.
  • Would you ever set `s-maxage` shorter than `max-age`?
    Rarely, and it is usually a mistake. A CDN can be purged on demand, while a browser cache cannot, so you want the long, absorbing TTL at the edge and the short, correctable one in the browser. The inverse leaves users pinned to stale content you have no way to recall while your origin takes the traffic anyway.

saying these in an interview costs you the question

  • Adding `public` reflexively and thereby allowing a CDN to cache authenticated responses
  • Treating `private` as a security or encryption guarantee
  • Thinking browsers honour `s-maxage`
  • Assuming a CDN cache key includes the session cookie or Authorization header by default
  • Setting a long `max-age` on personalised HTML because "the CDN will handle it"

context