Getting HTTP Caching Right: Cache-Control, ETag and Data Leaks in Shared Caches
The difference between no-cache and no-store, the right header per response type, 304s that never happen, and the mistakes that make a CDN hand one user's response to another.
Cache headers tend to be written once and forgotten. Then one of two complaints arrives: users keep seeing the old interface after a deploy, or, far worse, one user's account page is served to someone else. Both come from the same place. HTTP caching follows headers, not intuition, and when a header is missing, browsers and intermediaries fill the gap with their own guesses. This post covers choosing headers by response type, making revalidation actually work, and the mistakes that turn a shared cache into a data leak.
Two kinds of cache, two kinds of risk
A browser cache is a private cache: it stores responses for one user. A CDN, reverse proxy or corporate proxy is a shared cache: a response stored for one user can be handed to another. A mistake in the browser gives you stale content. A mistake in a shared cache gives you a leak.
So the first question is not "how long should this live" but "whose response is this". Anything personalised gets private or no-store. public and s-maxage are only for responses that are identical for everyone.
What the directives really mean
The pair people confuse most is no-cache and no-store. no-cache does not mean "don't cache". It means "store it, but check with the origin before every reuse". no-store means the response must not be written anywhere. Use no-store for content that should never touch a disk, such as a bank statement, and no-cache for HTML that changes often but can be revalidated cheaply.
max-age applies to both private and shared caches. s-maxage applies only to shared caches and overrides max-age there. That lets you give browsers a short lifetime and the CDN a long one, which is useful precisely because you can purge a CDN on deploy and you cannot purge a browser.
stale-while-revalidate lets an expired response be served for a while longer while a fresh copy is fetched in the background. stale-if-error lets a cache fall back to a stale copy when the origin returns errors. Support for both varies across clients and CDNs, so check your provider's documentation before relying on them.
Recommended headers per response type
In practice three classes cover almost everything.
Fingerprinted static assets (files whose name includes a content hash, like app.3f9a1c.js): when the content changes, the name changes, and an old name never points to new content. Give these the longest lifetime you can:
Cache-Control: public, max-age=31536000, immutable
immutable tells the browser not to send a revalidation request for the file even when the user reloads the page.
HTML and entry point documents: this is the document that carries the fingerprinted file names. If it sits in a cache for a long time, the new release is never loaded. For these:
Cache-Control: no-cache
ETag: "5d8c72a5"
Each request does a short revalidation, and if nothing changed the server answers with a bodyless 304 Not Modified.
Personalised API responses and authenticated pages:
Cache-Control: private, no-cache
or no-store if the data is sensitive.
In an nginx configuration the split looks like this:
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location / {
add_header Cache-Control "no-cache" always;
}
Revalidation: ETag and Last-Modified
When a response expires, the cache does not throw it away. It asks the origin with If-None-Match (for an ETag) or If-Modified-Since (for Last-Modified). Set up correctly, this replaces a full body transfer with a 304 of a few hundred bytes.
You can check your own endpoint like this:
curl -sI https://example.com/ | grep -i -E 'etag|cache-control|age'
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'If-None-Match: "5d8c72a5"' https://example.com/
If the second command does not print 304, revalidation is not working.
Pitfalls
No header does not mean no caching. A response with no Cache-Control but with a Last-Modified header may be cached heuristically. The HTTP specification allows it, and the common practice is a lifetime of roughly a tenth of the time since the file last changed. A file modified a month ago can therefore be reused for days without a single check. Send an explicit Cache-Control on every response.
add_header inheritance in nginx. The moment you write a single add_header inside a location block, every add_header defined at a higher level stops applying to that block. Your security headers can disappear without any warning. And without always, these headers are only added to successful status codes, so your error pages go out bare.
Different ETags on different servers. Servers that derive ETags from filesystem metadata (inode, modification time) can produce a different value for the same file on each machine. As the load balancer spreads requests, revalidation keeps failing and you never see a 304. Derive ETags from a content hash, or pin modification times in your build. Compression affects ETags too: some servers downgrade a strong ETag to a weak one (prefixed with W/) when they compress the body, which is expected.
Vary either splits the cache or kills it. Vary: Accept-Encoding is necessary and harmless. Vary: Cookie turns nearly every request into its own cache entry and your hit rate drops to zero. Rather than trying to store personalised responses "safely" in a shared cache through Vary, mark them private.
Headers that are not part of the cache key. If your application builds its response from a header such as X-Forwarded-Host but the CDN does not include that header in the cache key, an attacker can send one request with a poisoned value and have the resulting response served to everyone. This is cache poisoning. The rule is simple: every input that changes the response is either in the cache key or ignored by the application.
Leaks through path confusion. Some CDNs decide what to cache by file extension, treating anything ending in .css as static. If the application quietly handles /account/profile/x.css as /account/profile, the victim's personal page is cached as a stylesheet and exposed to anyone who requests that URL. Prefer a configuration that honours the origin's Cache-Control over extension based rules, and reject unknown paths with a 404.
Error responses getting cached. A 404 or 502 returned during a short outage, cached with a long lifetime, keeps showing the error well after the outage is over. Define separate and very short lifetimes for error codes.
Responses that carry Set-Cookie. Whether a CDN caches a response with a cookie on it differs between providers. If someone's session cookie lands in the cache and is handed to another user, the result is account takeover. Put private or no-store on every response that sets a cookie, and do not trust the provider default.
Purging versus versioned names
Purging the CDN after a deploy works, but it never reaches browser caches, and when a purge request fails nobody notices. Versioned file names remove the need to purge at all: a new release is a new name. For static assets the choice is clearly fingerprinted names. Keep purging for documents whose name must stay fixed, like HTML held at the CDN through s-maxage.
When not to cache
If a response is personalised and cheap to produce, do not try to put it in a shared cache. The gain is small and the leak risk is large. If data changes every second and a stale value leads to a wrong decision (stock levels, balances, prices), start with no-store, and if a measured load problem actually appears, solve it with a cache inside the application where you control invalidation. HTTP caching is built for content that is the same for everyone and tolerates a little staleness. Used outside that boundary, it does not solve problems, it hides them.
Add one line to your release checklist: for each response class, run curl -sI and look at Cache-Control, ETag and Age. If you do not see the value you expect, the cache is making the decision for you.