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.

Public vs Private cache scope across the delivery chain Diagram showing browser, CDN edge, and origin server. A response marked public may be stored at the CDN and browser. A response marked private may only be stored in the browser. Origin Server emits Cache-Control CDN / Shared Cache public → stores ✓ private → must skip ✗ s-maxage sets TTL max-age ignored here Browser Cache public → stores ✓ private → stores ✓ max-age sets TTL response response

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-store supersedes all other storage directives. It has absolute precedence regardless of public or max-age.
  • private overrides s-maxage for shared caches. Combining private with s-maxage is contradictory; shared caches must honor private.
  • public does not override no-store. no-store still wins.
  • Neither public nor private affects the Vary header’s role in cache key construction. A public response with Vary: Authorization will 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.

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:

  1. Open DevTools → Network tab.
  2. Disable cache (the checkbox in the Network panel toolbar).
  3. Load the page, observe Cache-Control response headers per resource.
  4. Re-enable cache and reload. Resources served from browser cache show (disk cache) or (memory cache) in the Size column.
  5. For private resources with no-cache, expect a 304 Not Modified on subsequent loads (the browser sends If-None-Match and 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

  1. Missing public on Authorization-gated responses that should be shared. RFC 9111 Section 3.5 bars shared caches from storing responses to requests that include Authorization unless the response explicitly includes public, s-maxage, or must-revalidate. Without public, your CDN will bypass caching for every authenticated API request even when the data is safe to share.

  2. private on a load-balanced origin silently not propagating. Some load balancers (AWS ALB, HAProxy in certain modes) strip Cache-Control response 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, since private was never delivered.

  3. Combining public with Vary: 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 the Vary: Cookie or switch to private.

  4. Browser storage partitioning altering expected private behavior. Modern browsers (Chrome 86+, Firefox 103+, Safari 13.1+) partition the HTTP cache by top-level site origin. A private response cached in an embedded iframe from api.example.com is not accessible from example.com in the top frame — each partition is isolated. This is correct security behavior but can surprise developers who expect cross-origin cache reuse.

  5. CDNs ignoring private when s-maxage is also present. Some older CDN implementations erroneously honor s-maxage even when private is set, treating s-maxage as the authoritative directive for shared caches. RFC 9111 is unambiguous: private must win. Audit your CDN’s behavior explicitly if you ever emit both.

  6. no-transform and private interactions. Some mobile proxies and compression middleboxes transcode or compress responses before forwarding. The no-transform directive prevents this, but only applies to stored responses. If a proxy is transforming a private response in transit (without storing it), no-transform on the response is what stops the transformation — not private.

  7. Caching Set-Cookie through a shared cache. If a response containing Set-Cookie is marked public without field-level private="Set-Cookie", a CDN may cache and replay the Set-Cookie header to subsequent users. This is a session fixation vector. Either use private on the whole response, strip Set-Cookie at the CDN, or use Cache-Control: public only on responses that never include Set-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.


Back to Cache-Control Directives & Header Combinations