Cache-Control Directives for CDNs and Proxies

This guide is part of the Cache-Control and Clear-Site-Data reference and covers the directives that govern shared caches — CDNs, reverse proxies, and corporate forward proxies — as opposed to the private browser cache. The distinction is not cosmetic: public versus private and s-maxage versus max-age decide whether a per-user, authenticated response is stored once at the edge and then served verbatim to every other visitor. Get it wrong and a logged-in dashboard leaks to anonymous users. This page fixes the exact directives, the per-platform config, and the verification commands that prove a shared cache is behaving.

Configuration Syntax & Exact Values

A shared cache is any cache not dedicated to a single user: a CDN POP, an Nginx/Varnish reverse proxy, or an ISP/corporate proxy. Cache-Control targets these separately from the browser through the public/private split and the s-maxage directive.

Cache-Control: public, max-age=60, s-maxage=600

This says: any cache may store the response; a browser may reuse it for 60 seconds; a shared cache may reuse it for 600 seconds (s-maxage overrides max-age for shared caches only). The two authenticated-safe forms:

Cache-Control: private, max-age=0
Cache-Control: no-store

private forbids storage in shared caches while still permitting the browser to cache; no-store forbids storage everywhere. For a page carrying session-specific data, one of these two is mandatory.

Directive Applies to Effect Use when
public All caches Explicitly permits shared caches to store the response, even for requests with Authorization. Static assets, anonymous HTML
private Browser only Shared caches (CDN, proxy) MUST NOT store it; browser may. Per-user HTML on a shared device is acceptable
s-maxage=N Shared caches Freshness lifetime for shared caches only; overrides max-age and Expires for them. Long edge TTL, short browser TTL
max-age=N All caches Freshness lifetime in seconds; shared caches ignore it if s-maxage is present. Baseline TTL
no-cache All caches May store, but MUST revalidate with the origin before every reuse. Content that changes unpredictably
no-store All caches MUST NOT store any part of the response anywhere. Secrets, tokens, PII

The trap directive is public. Under RFC 9111 the presence of an Authorization request header normally makes a response non-shared-cacheable — but public explicitly overrides that safeguard. Writing Cache-Control: public on an authenticated route re-enables shared caching of a response that was protected by default.

Directive scope across cache tiers Matrix showing which cache tiers may store a response under public, private, and no-store. Directive Browser cache CDN / edge Proxy public stored stored stored private stored blocked blocked no-store blocked blocked blocked "stored" = the tier may retain and reuse the response; "blocked" = it must not.
Only private and no-store keep an authenticated response out of shared caches.

Server-Side Configuration

The pattern is identical everywhere: split static assets (long, public, s-maxage) from authenticated HTML (private or no-store), and ensure the edge honours the split. Configure at the origin first — the CDN inherits origin Cache-Control unless you override it.

Nginx

# Authenticated / per-user HTML: never shared-cache it
location /app/ {
    add_header Cache-Control "private, no-store" always;
}

# Anonymous HTML: short browser TTL, long edge TTL
location = /pricing {
    add_header Cache-Control "public, max-age=60, s-maxage=600" always;
}

# Immutable static assets: long TTL for every cache
location ~* \.(css|js|woff2|png|jpg|svg)$ {
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}

The always flag emits the header on 4xx/5xx too, so an error page rendered on /app/ is not left silently shared-cacheable. See the full Nginx reference for proxy_cache interaction.

Apache

<Location "/app/">
    Header always set Cache-Control "private, no-store"
</Location>

<LocationMatch "^/pricing$">
    Header always set Cache-Control "public, max-age=60, s-maxage=600"
</LocationMatch>

<FilesMatch "\.(css|js|woff2|png|jpg|svg)$">
    Header always set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>

always (versus the default onsuccess) sets the header on internally generated error documents, which otherwise ship with no cache directive. Requires mod_headers. The Apache guide covers mod_cache scoping.

Cloudflare

Cloudflare respects origin Cache-Control for the browser TTL but uses its own Edge Cache TTL for the POP. To make s-maxage authoritative, or to force a bypass on authenticated paths, use a Cache Rule.

// Cloudflare Cache Rule (Rules > Caching)
// Expression:
(http.request.uri.path contains "/app/")
// Action: Bypass cache
// Result: the edge never stores /app/ responses regardless of headers

For anonymous routes, set the matching Cache Rule action to Eligible for cache with Edge TTL: Use cache-control header if present, so s-maxage=600 drives the edge. Without this, Cloudflare’s default may cache only by file extension and ignore your HTML directives entirely. The Cloudflare reference details Transform Rules for header rewriting.

Node/Express

// Per-route, not global
app.get('/app/*', (req, res, next) => {
  res.set('Cache-Control', 'private, no-store');
  next();
});

app.get('/pricing', (req, res) => {
  res.set('Cache-Control', 'public, max-age=60, s-maxage=600');
  res.render('pricing');
});

Helmet does not set Cache-Control; set it explicitly per route. A framework default of no-cache on dynamic responses is common — override it deliberately rather than relying on it.

Diagnostic & Verification Steps

The core question is always: did a shared cache store this, and for how long? Read the origin directive, then read the CDN’s cache-status header.

Confirm the origin sends the right split:

curl -sI https://example.com/app/dashboard | grep -i cache-control
# cache-control: private, no-store

curl -sI https://example.com/pricing | grep -i cache-control
# cache-control: public, max-age=60, s-maxage=600

Confirm the edge honoured it. Cloudflare reports cf-cache-status; Fastly/Varnish report x-cache and an Age:

curl -sI https://example.com/app/dashboard | grep -iE 'cf-cache-status|age|x-cache'
# cf-cache-status: BYPASS

curl -sI https://example.com/pricing | grep -iE 'cf-cache-status|age|x-cache'
# cf-cache-status: HIT
# age: 47

A HIT or a non-zero Age on /app/dashboard is a leak: a shared cache stored an authenticated response. BYPASS/MISS is correct there. The Age header (RFC 9111 §5.1) tells you how many seconds ago the shared cache fetched the response — compare it against s-maxage to confirm freshness math.

The authenticated-page CDN leak Sequence showing a CDN storing a logged-in user's response and serving it to an anonymous visitor. Logged-in user CDN / shared cache Origin GET /account + session cookie forward 200 + Cache-Control: public stores per-user HTML 200 (user's own page) Anonymous visitor GET /account (no cookie) HIT: other user's page leaked
With public on an authenticated route, the CDN serves one user's page to everyone until TTL expires.

Vary correctness. When a shared route legitimately differs by request header, Vary forces the cache to key on it. A response cached under one language and served for another is a Vary bug:

curl -sI -H 'Accept-Encoding: gzip' https://example.com/pricing | grep -i vary
# vary: Accept-Encoding

Never rely on Vary: Cookie to protect authenticated content — cookie values are high-cardinality and many CDNs strip or ignore Vary: Cookie, so the response can still be shared. Use private/no-store for authentication, and reserve Vary for content negotiation (Accept-Encoding, Accept-Language).

Edge Cases, Security Implications & Safe Rollback

Choosing the shared-cache directive Decision tree mapping response type to the correct Cache-Control directive for shared caches. Response is per-user? yes no Contains secret / token / PII? yes no no-store private, max-age=0 Changes unpredictably? yes no no-cache (revalidate) public, s-maxage=N Authenticated branches never reach a "public" leaf — that is the whole point.
Per-user responses branch left to no-store or private; only fully anonymous content earns public, s-maxage.

Conclusion

Roll out on one route at a time. In staging, set private, no-store on an authenticated path and public, max-age=60, s-maxage=600 on an anonymous one, then prove the split with curl -sI reading both cache-control and cf-cache-status/x-cache. Start anonymous routes with a short s-maxage so a mistake expires in minutes, verify no authenticated route ever returns a shared-cache HIT, then widen the edge TTL and promote to production with a purge path in place.

Frequently Asked Questions

What is the difference between max-age and s-maxage? max-age sets the freshness lifetime for every cache; s-maxage sets it for shared caches only (CDNs, proxies) and overrides max-age for them. Use both to give the browser a short TTL and the edge a longer one, for example max-age=60, s-maxage=600.

Does private prevent a CDN from caching my page? Yes. private instructs shared caches — CDN POPs and proxies — not to store the response, while still allowing the user’s browser to cache it. It is the correct directive for per-user HTML that is not secret. For tokens or PII, use no-store so even the browser does not retain it.

Why did my CDN serve one user’s page to another? The authenticated response carried Cache-Control: public (or a “cache everything” edge rule overrode the origin), so the shared cache stored the first user’s page and replayed it on a HIT to everyone else. Set private or no-store on authenticated routes and add an edge bypass rule.

Can I use Vary: Cookie to keep authenticated responses separate? No. Cookie values are high-cardinality and many CDNs strip or ignore Vary: Cookie, so the response can still be shared across users. Protect authentication with private/no-store; reserve Vary for content negotiation such as Accept-Encoding and Accept-Language.

Is no-cache the same as no-store for a shared cache? No. no-cache permits the shared cache to store the response but forces revalidation with the origin before every reuse. no-store forbids storing it at all. For secrets use no-store; for content that must always be fresh but is not sensitive, no-cache is enough.