Public vs Private Cache Scope: Controlling Who Stores Your Responses
public and private are the two scope directives that tell every cache in the delivery chain whether it is permitted to store a response. Getting this wrong means either leaking user-specific data through a shared CDN or forcing unnecessary origin round-trips on responses that could be safely edge-cached. One directive, misapplied, can destroy both performance and security simultaneously.
Quick reference — choose one scope directive per response:
# Public: any cache (CDN, proxy, browser) may store this response
Cache-Control: public, max-age=3600, s-maxage=86400
# Private: browser-only; shared caches must not store
Cache-Control: private, max-age=300
# Sensitive: no storage anywhere
Cache-Control: no-store
Mechanism and RFC 9111 Alignment
RFC 9111 Section 5.2.2.5 defines public as a directive that “indicates that any cache may store the response, even if the response would not normally be stored by a shared cache.” Section 5.2.2.7 defines private as indicating that “the response message is intended for a single user and must not be stored by a shared cache.”
The normative implication of private:
“A shared cache MUST NOT store a response to a request that includes an Authorization header field.” — RFC 9111, Section 3.5
Without an explicit public directive, any response that includes an Authorization request header is non-cacheable by shared caches by default. The public directive explicitly overrides this restriction, authorizing shared caches to store even authenticated responses. This is the only scenario where public changes behavior that would otherwise be blocked — for responses without Authorization, shared caches may already cache by default.
The distinction is architectural: public grants permission to shared intermediaries; private revokes it. Neither directive says anything about whether the response should be cached — TTL directives like max-age and s-maxage govern lifetime.
Scope and Precedence
Shared caches include CDN edge nodes, reverse proxies (Nginx, Varnish, HAProxy), corporate gateway caches, and ISP transparent proxies. Private caches are exclusively the end-user’s browser (or equivalent user-agent storage).
The following precedence table shows how scope directives interact with other directives:
| Directive combination | Shared cache behavior | Browser behavior |
|---|---|---|
public, max-age=3600 |
May cache for 3600 s | May cache for 3600 s |
public, s-maxage=86400, max-age=3600 |
Caches for 86400 s | Caches for 3600 s |
private, max-age=300 |
Must not store | May cache for 300 s |
private, s-maxage=86400 |
Must not store (contradictory — private wins) |
May use max-age fallback if present; ignores s-maxage |
no-store |
Must not store | Must not store |
public, no-store |
Must not store (no-store wins) |
Must not store |
private, no-store |
Must not store | Must not store |
Key precedence rules from RFC 9111:
no-storesupersedes all other storage directives. It has absolute precedence regardless ofpublicormax-age.privateoverridess-maxagefor shared caches. Combiningprivatewiths-maxageis contradictory; shared caches must honorprivate.publicdoes not overrideno-store.no-storestill wins.- Neither
publicnorprivateaffects theVaryheader’s role in cache key construction. Apublicresponse withVary: Authorizationwill create separate cache entries per authorization value — it will not be served to unauthenticated users.
For directive stacking order and collision rules, see Header Stacking and Directive Precedence.
Implementation Patterns
Versioned static assets (images, JS, CSS with content-addressed filenames)
Cache-Control: public, max-age=31536000, immutable
The immutable extension tells browsers not to issue conditional revalidation during navigations within the TTL window. Use only with content-addressed filenames (e.g., app.a3f1bc.js) — changing the file name on deploy acts as the cache-bust. CDNs store this indefinitely until their own TTL or a purge fires.
Personalized API responses
Cache-Control: private, no-cache
Vary: Cookie, Authorization
private blocks CDN storage. no-cache forces the browser to revalidate with the origin on every use (it will send If-None-Match or If-Modified-Since). The Vary header is a separate response header, not a Cache-Control directive — it partitions the browser’s cache key by cookie/authorization value so different user sessions do not share a cached entry.
Authenticated HTML pages with tolerable staleness
Cache-Control: private, max-age=60, must-revalidate
The browser may serve the page from cache for up to 60 seconds without contacting the origin. After 60 seconds, must-revalidate mandates a conditional request before reuse. private ensures the CDN never stores or serves this page to any other user.
CDN-accelerated authenticated responses (API gateway pattern)
Cache-Control: public, s-maxage=300, max-age=0, must-revalidate
Vary: Authorization
Using Vary: Authorization partitions the CDN cache by the Authorization header value, so each token yields a distinct cached entry. The CDN caches for 300 seconds per token; browsers revalidate on every use (max-age=0, must-revalidate). This pattern requires the CDN to support Vary correctly — verify with the vendor before deploying. For guidance on combining these TTL directives safely, see How to Combine Cache-Control Directives Safely.
Truly sensitive data (tokens, PCI-scope, medical records)
Cache-Control: no-store
Pragma: no-cache
Use no-store when zero persistence is required at any layer. Pragma: no-cache is a legacy HTTP/1.0 header that some ancient proxies still respect. For HIPAA and PCI DSS compliance, no-store is the minimum required directive on any response that includes protected health information or cardholder data.
Server and CDN Configuration
Nginx
# Versioned static assets — long CDN + browser TTL
location ~* \.(js|css|woff2|png|svg)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Authenticated API — browser only, always revalidate
location /api/user/ {
add_header Cache-Control "private, no-cache";
add_header Vary "Cookie, Authorization";
}
# Sensitive data — no storage
location /account/payment {
add_header Cache-Control "no-store";
}
Apache
# Versioned static assets
Header set Cache-Control "public, max-age=31536000, immutable"
# Authenticated API responses
Header set Cache-Control "private, no-cache"
Header set Vary "Cookie, Authorization"
# Sensitive endpoints
Header set Cache-Control "no-store"
Cloudflare (Page Rules / Cache Rules)
Cloudflare respects private by default — responses with Cache-Control: private receive a CF-Cache-Status: BYPASS or DYNAMIC and are not stored at the edge. However, Cloudflare’s default behavior may cache responses that your origin marks as public with short TTLs differently from what you expect — the Edge Cache TTL setting in Cloudflare overrides origin TTLs when set.
To respect origin headers precisely, set Cache Level to “Respect Existing Headers” in the Cache Rule:
Cache Rule: URI Path matches /api/*
→ Cache eligibility: Bypass cache
Cache Rule: URI Path matches /static/*
→ Cache eligibility: Eligible for cache
→ Edge Cache TTL: Respect origin
For APIs requiring authentication pass-through with per-user CDN caching, enable Cache by Device Type off and ensure the custom cache key includes the Authorization header. For how Cloudflare constructs cache keys from headers, see How CDN Cache Keys Are Generated.
Interaction with Related Directives
public / private and s-maxage
s-maxage sets the freshness lifetime for shared caches specifically. Its presence implicitly authorizes shared caching — meaning s-maxage alone on a response without private is treated as if public were present. However, explicitly adding public alongside s-maxage is clearer and is the recommended practice.
If private and s-maxage both appear, private wins: the shared cache must not store despite any s-maxage value. Do not combine them — the intent is contradictory and CDN behavior in this case is implementation-defined.
public / private and no-cache
no-cache does not disable storage — it requires revalidation before reuse. A public, no-cache response can be stored by the CDN; the CDN will validate with origin on every request before serving it. This is useful for content that must always be current but can tolerate CDN storage for deduplication under high load.
private, no-cache restricts storage to the browser and requires browser-level revalidation. This is the correct pattern for personalized API data that is safe in the browser but must not be served to other users from a shared cache.
public and Vary
Vary is a separate response header that creates distinct cache entries per unique combination of the specified request headers. A public response with Vary: Accept-Encoding is shared-cache-eligible but creates separate entries for gzip, br, and identity encodings. A public response with Vary: Cookie creates a separate entry per cookie value — which on most sites means per user. This effectively negates the performance benefit of public for personalized content. For correct Vary usage with CDN routing, see Mapping Vary Headers to Edge Routing.
Field-name private syntax
RFC 9111 supports a quoted argument form: private="Set-Cookie". This restricts only the named header field(s) to private storage while allowing the rest of the response to be shared-cache-eligible. Browser and CDN support for this granular syntax is inconsistent — Varnish supports it; most CDN platforms do not. Test before relying on it.
Verification Workflow
Step 1 — Confirm the origin is emitting the correct directive:
curl -sI https://example.com/api/user/profile \
| grep -i "cache-control"
Expected for a private endpoint: cache-control: private, no-cache. Any s-maxage in this response is a misconfiguration.
Step 2 — Confirm the CDN is honoring private:
curl -sI https://example.com/api/user/profile \
| grep -iE "cf-cache-status|x-cache|age"
For Cloudflare: CF-Cache-Status: DYNAMIC or BYPASS confirms the response was not stored. A HIT or an Age header on a private endpoint is a critical misconfiguration — the CDN is sharing user-specific data.
Step 3 — Confirm public responses reach the CDN cache:
# First request — expect MISS
curl -sI https://example.com/static/app.a3f1bc.js \
| grep -iE "cf-cache-status|x-cache|age"
# Second request — expect HIT with Age > 0
curl -sI https://example.com/static/app.a3f1bc.js \
| grep -iE "cf-cache-status|x-cache|age"
Step 4 — Browser DevTools inspection:
- Open DevTools → Network tab.
- Disable cache (the checkbox in the Network panel toolbar).
- Load the page, observe
Cache-Controlresponse headers per resource. - Re-enable cache and reload. Resources served from browser cache show
(disk cache)or(memory cache)in the Size column. - For
privateresources withno-cache, expect a304 Not Modifiedon subsequent loads (the browser sendsIf-None-Matchand the origin confirms the resource is unchanged).
Step 5 — Check for stripped directives at intermediate layers:
WAFs, load balancers, and API gateways sometimes strip or rewrite Cache-Control headers. Capture headers at both the origin and the browser to detect discrepancies:
# At origin (bypass CDN using origin IP or internal URL)
curl -sI -H "Host: example.com" http://<origin-ip>/api/user/profile \
| grep -i cache-control
# At edge (via CDN)
curl -sI https://example.com/api/user/profile \
| grep -i cache-control
If the origin emits private but the CDN shows HIT, a load balancer or WAF is stripping the directive in transit. Audit every layer in the request path.
Failure Modes and Gotchas
-
Missing
publiconAuthorization-gated responses that should be shared. RFC 9111 Section 3.5 bars shared caches from storing responses to requests that includeAuthorizationunless the response explicitly includespublic,s-maxage, ormust-revalidate. Withoutpublic, your CDN will bypass caching for every authenticated API request even when the data is safe to share. -
privateon a load-balanced origin silently not propagating. Some load balancers (AWS ALB, HAProxy in certain modes) stripCache-Controlresponse headers or replace them with a default. The browser receives no scope directive, falls back to heuristic caching, and may cache the response longer than intended — but a CDN downstream of the ALB may cache it too, sinceprivatewas never delivered. -
Combining
publicwithVary: Cookie. This is valid per RFC 9111 but produces a CDN cache partition per cookie value. On a site where cookies contain session IDs, this means a separate cache entry per user — effectively preventing any CDN hit rate. Either remove theVary: Cookieor switch toprivate. -
Browser storage partitioning altering expected
privatebehavior. Modern browsers (Chrome 86+, Firefox 103+, Safari 13.1+) partition the HTTP cache by top-level site origin. Aprivateresponse cached in an embedded iframe fromapi.example.comis not accessible fromexample.comin the top frame — each partition is isolated. This is correct security behavior but can surprise developers who expect cross-origin cache reuse. -
CDNs ignoring
privatewhens-maxageis also present. Some older CDN implementations erroneously honors-maxageeven whenprivateis set, treatings-maxageas the authoritative directive for shared caches. RFC 9111 is unambiguous:privatemust win. Audit your CDN’s behavior explicitly if you ever emit both. -
no-transformandprivateinteractions. Some mobile proxies and compression middleboxes transcode or compress responses before forwarding. Theno-transformdirective prevents this, but only applies to stored responses. If a proxy is transforming aprivateresponse in transit (without storing it),no-transformon the response is what stops the transformation — notprivate. -
Caching
Set-Cookiethrough a shared cache. If a response containingSet-Cookieis markedpublicwithout field-levelprivate="Set-Cookie", a CDN may cache and replay theSet-Cookieheader to subsequent users. This is a session fixation vector. Either useprivateon the whole response, stripSet-Cookieat the CDN, or useCache-Control: publiconly on responses that never includeSet-Cookie.
FAQ
Does public mean the response will definitely be cached?
No. public grants permission for shared caches to store the response — it does not mandate that they do so. A CDN may still choose not to cache based on its own policies (e.g., minimum TTL thresholds, query-string passthrough rules, or response size limits). public removes the RFC 9111 restriction; CDN configuration determines whether caching actually occurs.
Can I set private on a response and still have the CDN deliver it?
Yes. The CDN passes through private responses to the browser without storing them. The CDN acts as a transparent proxy for those responses. Performance is not improved by the CDN for these responses (no edge hit), but the CDN may still provide TLS termination, DDoS protection, and routing benefits.
What happens if I omit both public and private?
The response is subject to heuristic caching. Per RFC 9111 Section 4.2.2, shared caches may cache the response if no explicit scope directive is present, using an internally computed freshness lifetime (typically a fraction of the Last-Modified age). This is unpredictable — always emit an explicit scope directive in production.
Is private sufficient for GDPR or HIPAA compliance?
private prevents shared caches from storing the response but does not prevent browser storage. For GDPR or HIPAA compliance, use no-store on any response containing personally identifiable information or protected health data. private alone is not sufficient when the requirement is “no persistence anywhere.”
Does public expose my data to other users?
Only if your CDN serves the same cached entry to multiple users. A public response with Vary: Authorization creates a separate cache entry per authorization value — different users with different tokens get distinct entries. Without Vary, a public response is shared across all users requesting the same URL, so only use public on responses where the content is identical for all requesters.
Related
- The freshness vs validation models page covers how
max-ageTTLs interact withETag-based revalidation — essential context for deciding whenprivate, no-cacheis correct versusprivate, max-age=N. - Using Vary: Accept-Encoding Without Fragmenting Cache explains how to avoid accidental cache fragmentation when
publicresponses include encoding negotiation headers. - Origin Shielding and Request Collapsing shows how
publicresponses benefit from CDN shielding layers that coalesce concurrent cache misses into a single origin request. - Cache-Control Best Practices for REST APIs applies the
public/privatedecision framework to RESTful API endpoint classification.