Caching can make an application faster by reusing a previous response instead of generating it again. That same reuse becomes a security problem when a response created for one user can be returned to another.
Imagine /account renders the signed-in user’s email address and recent activity. The application checks authentication correctly at the origin server. A shared cache in front of it stores Alice’s response under a key based only on /account. Bob later requests the same path and the cache reuses Alice’s stored response without contacting the origin. The authentication code is correct, but it never gets a chance to run for Bob’s request.
This is a cache-boundary failure: the system reused data across security contexts that were not equivalent. The result can be disclosure of personal or privileged information.
This article develops a practical mental model for deciding whether a response may be cached, where it may be stored, and what must be part of the cache identity when reuse is allowed.
Treat reuse as an authorization decision
A cache does not normally ask, “Is this user allowed to see this response?” It asks whether a stored response is reusable for the current request.
That distinction matters because cache reuse can happen before application authorization runs.
A useful mental model is:
request
|
v
cache lookup ---- hit ----> stored response
|
miss
v
application authorization
|
v
new responseIf a cache hit returns the wrong stored representation, strong authorization at the application layer cannot repair that response after the fact.
The defensive question is therefore not merely “Is this endpoint authenticated?” It is:
Under what conditions may a response generated for one request be reused for another request?
For public content, the answer can be broad. For personalized or authorization-dependent content, it is usually much narrower.
Understand private and shared caches
HTTP distinguishes two important cache scopes.
A private cache is dedicated to one user, commonly as part of a browser. A shared cache can reuse responses for multiple users, such as a reverse proxy or content delivery network.
This difference creates the central trust boundary. A response containing Alice’s account data may be reasonable to keep in Alice’s private browser cache under the application’s requirements. The same response is usually inappropriate for a shared cache unless the cache has an explicit, correctly designed mechanism that keeps Alice’s representation separate from every other security context.
For a personalized response that may be stored privately but must not be stored by a shared cache, HTTP provides:
Cache-Control: privateFor a response that should not be stored by either private or shared caches, HTTP provides:
Cache-Control: no-storeThese directives answer different questions. private controls where a response may be stored. no-store says compliant caches must not store the response for later reuse.
Do not confuse no-cache with no-store. no-cache permits storage but requires successful validation with the origin before reuse. That can be useful when freshness is the concern, but it does not mean “do not store this response.”
Start with the safest simple design
Suppose an application has two endpoints:
GET /news -> identical public news for everyone
GET /account -> data for the signed-in userA simple policy is:
/news
Cache-Control: public, max-age=300
/account
Cache-Control: private, no-cacheThe public response can be reused broadly for five minutes. The account response may be stored by a private cache, but a shared cache should not store it, and a cache must validate it before reuse.
This is only a teaching example. Production policy depends on the sensitivity of the data, application semantics, browser behaviour you require, and the caches in your delivery path. For especially sensitive responses where storage itself is undesirable, no-store may be the appropriate choice.
The important design principle is to choose cache policy from the response’s security semantics rather than from a general performance preference.
A cache key defines which requests are considered equivalent
Application caches and intermediaries need a cache key: the identity used to decide whether a stored value matches a new request.
Consider an application-level cache:
key = request.path
value = render_account(current_user)
cache.put(key, value)If the path is /account for every user, the key collapses different users into one cache entry. The cache is effectively claiming that all requests for /account are equivalent even though the response depends on user identity.
Adding user identity changes that assumption:
key = (request.path, authenticated_user_id)Now Alice and Bob map to different entries.
But this change is correct only if user identity is the complete security-relevant distinction. Suppose the response also depends on the active organization, selected tenant, role, locale, or another request property. If that property can change what data is permitted or rendered, omitting it from the key can still cause incorrect reuse.
The rule is broader than “put the user ID in the key”: every property that changes whether two cached representations are interchangeable must be reflected in the cache design.
Sometimes that means extending the key. Sometimes it means not caching the response at that layer at all.
Do not use secrets as cache keys casually
A tempting implementation is to key a cache directly by a session cookie or bearer token. That separates many users, but it creates another problem: credentials are now copied into cache metadata, logs, metrics, traces, or debugging interfaces that may expose keys.
Prefer a stable, non-secret identifier when the cache genuinely needs per-user separation. If authorization depends on more than identity, include the necessary non-secret security context or design the cached object so that authorization is performed after retrieval and before disclosure.
This illustrates an important trade-off. Making a key more specific can reduce cross-user reuse, but putting sensitive credentials into infrastructure creates new exposure. The cache key should encode the minimum non-secret context required to make reuse correct.
Vary is useful, but it is not a general authorization system
HTTP’s Vary response header tells caches that selected request header fields affect which stored response can satisfy a later request.
For example:
Vary: Accept-LanguageThis can separate representations that differ by language.
It is dangerous to treat Vary as a universal fix for personalized caching. First, it only describes variation by request header fields. Second, using high-cardinality or secret-bearing headers can create operational or confidentiality problems. Third, application authorization may depend on server-side state that is not represented by a request header at all.
If a response is user-specific, Cache-Control: private is often a clearer boundary for HTTP shared caches than trying to enumerate user identity through Vary.
Use Vary when a response is legitimately cacheable and a particular request header changes the representation. Do not use it as a substitute for understanding the authorization context.
Authentication headers have special HTTP caching rules
HTTP gives requests carrying an Authorization header additional protection: a shared cache normally cannot store the corresponding response unless the response contains a directive that explicitly permits shared caching.
That is useful, but it is not a complete personalized-content policy.
Many applications authenticate with cookies rather than the Authorization header. A cookie by itself does not make a response private. A shared cache can also be configured incorrectly or given explicit directives that make a response shareable.
Therefore, do not rely on “the endpoint requires login” as your cache control. Send an explicit response policy that matches the content.
Be particularly careful with public and shared-cache directives such as s-maxage. They intentionally allow forms of shared caching. Apply them to authorization-dependent responses only when you have deliberately designed and tested the sharing boundary.
Separate public structure from private data when useful
Disabling shared caching for an entire personalized page is often the simplest correct design. When performance requirements justify more complexity, separate data by security boundary rather than weakening that policy.
For example, a page may consist of:
public application shell -> shared cache
user account data -> private or uncached responseThe public shell contains no user-specific information, so broad reuse is straightforward. The account request has an explicit private policy and is authorized independently.
This design can preserve much of the performance benefit without making a shared cache responsible for distinguishing every user’s complete authorization state.
More sophisticated per-user or per-tenant caching can be valid, especially inside controlled application infrastructure. It requires stronger assumptions: correct key construction, trustworthy isolation, predictable invalidation, and tests that prove one security context cannot receive another context’s entry.
Complex caching should be justified by measured need because every additional dimension is another opportunity for an equivalence mistake.
Invalidation is part of the security model
Even a correctly separated cache can become wrong when authorization changes.
Suppose a user is removed from an organization. A cached response keyed by (path, user_id, organization_id) may still contain organization data generated while access was valid. If the application continues serving that entry until its normal expiry, revocation is delayed by the cache lifetime.
You need an explicit answer to this question:
How quickly must an authorization change affect cached data?
For low-risk data, a short lifetime may be acceptable. For sensitive access changes, invalidate affected entries when permissions change, include an authorization-state version in the cache identity, or avoid caching the protected representation.
The right choice depends on the required revocation speed and operational complexity. Short lifetimes reduce the stale-access window but increase origin work. Active invalidation can react faster but adds failure modes. Versioned keys can make old entries unreachable, but the version source itself must remain correct.
Caching does not change the authorization requirement. It changes how long a previous authorization decision can influence later responses.
Verify the boundary, not only the happy path
A useful test uses at least two identities with different data or permissions.
For a personalized endpoint:
- Request the resource as user A and record the response.
- Request the same URL as user B through the same caching path.
- Confirm that B never receives A’s personalized fields.
- Repeat after changing A’s or B’s relevant permissions.
- Inspect response cache directives and, where available, cache-hit diagnostics.
- Test anonymous, authenticated, and privileged variants if the endpoint behaves differently for them.
The important property is cross-context isolation. A test that repeatedly requests the endpoint as one user can prove that caching works while completely missing the security failure.
For application-level caches, add automated tests around cache-key construction. Two requests that are not authorization-equivalent must not resolve to a shared protected representation.
Know what cache controls do not solve
Correct cache boundaries reduce the risk of one user’s response being reused for another user. They do not protect against every way sensitive data can leak.
They do not repair broken authorization at the origin. They do not make a compromised cache trustworthy. no-store is an instruction to compliant caches, not encryption or an access-control boundary against malicious infrastructure. Cache policy also does not replace transport security, output handling, logging hygiene, or session protection.
The threat model is narrower: an otherwise functioning cache must not broaden who can receive a response or keep authorization-dependent data usable longer than the application’s security policy permits.
Make cacheability an explicit property of each response class
A practical design review can classify responses by who may receive the same representation:
same for everyone
-> shared caching may be appropriate
same only for one user or security context
-> private or explicitly partitioned caching
storage itself is unacceptable
-> no-store
changes in authorization must take effect quickly
-> short lifetime, active invalidation, versioning, or no cachingThis is more reliable than adding cache headers after performance testing because it starts from the trust boundary.
When reviewing a cached response, ask three questions: Who created this representation? Which later requests are allowed to reuse it? What event makes that decision no longer valid?
If those answers are explicit, cache configuration becomes an implementation of a security decision rather than an accidental side effect of performance infrastructure.