
Premature Caching, Broken Authorization: A Real-World Security Regression
Introduction
I treat premature caching on authenticated routes as a security regression, not a tuning mistake.
Once a response can be reused before authorization runs, the cache stops being a passive performance layer and becomes part of the trust boundary. If the key is too broad, or if middleware runs in the wrong order, one user can get a response that was generated under another user’s privileges. That can show up as stale access, exposed account data, or protected functionality that keeps working after a role change.
The bug is easy to overlook because the code often looks clean. The route is still “protected,” the cache is still “small,” and a single-user test still passes. The failure only appears when you ask the harder question: what happens when two identities hit the same route with different authorization state?
Why this regression matters in real systems
This tends to show up in apps that mix:
- server-rendered pages with personalized fragments
- API responses keyed only by path
- reverse proxies or CDNs placed in front of app middleware
- session-based auth where the cache ignores
Cookie - role-based authorization where the response body changes by privilege
The dangerous part is that a cache hit can short-circuit the rest of the request path. If the wrong response is served before auth middleware runs, the backend never gets a chance to deny it.
The short version of the bug
The regression usually looks like this:
- the app receives a request for a protected route
- the cache layer checks for a reusable response
- the cache finds a match on URL alone, or URL plus a weak key
- the response is returned immediately
- auth middleware and per-request access checks never execute
That ordering is the bug. The cache is not just storing responses; it is deciding whether the security code gets to run.
What premature caching changes in the request path
Normal authorization flow before caching
In the safe version, authorization happens before any response is reused.
A request path usually looks like this:
- parse session or token
- identify the user
- check tenant, role, or entitlement
- build the response
- optionally cache the response if it is safe to share or safe to store privately
That order matters because authorization is evaluated against the current request context. The backend can deny access, degrade the response, or compute a personalized version before anything is reused.
A good mental model is: auth decides whether this request may proceed, cache decides how to reuse the result afterward.
The regression: cache lookup happens first
The broken path swaps those two concerns:
- read request path
- look up a cached response
- return it if present
- only then reach auth middleware, if control reaches it at all
In many frameworks, “return it if present” means the request never reaches later middleware. That is what makes the regression serious. The auth code can be correct and still be bypassed entirely.
Here is a minimal shape of the bug in Express-style middleware:
const cache = new Map();
function cacheFirst(req, res, next) {
const key = req.method + ":" + req.originalUrl;
if (cache.has(key)) {
const hit = cache.get(key);
res.set(hit.headers);
return res.status(hit.status).send(hit.body);
}
const originalSend = res.send.bind(res);
res.send = (body) => {
cache.set(key, {
status: res.statusCode,
headers: {
"Content-Type": res.get("Content-Type") || "text/plain",
},
body,
});
return originalSend(body);
};
next();
}
function requireAuth(req, res, next) {
if (!req.headers.cookie?.includes("session=")) {
return res.status(401).send("unauthorized");
}
next();
}
app.use(cacheFirst);
app.use(requireAuth);
app.get("/billing", (req, res) => {
res.type("text/plain").send("billing panel for current user");
});
This is simplified on purpose, but the failure mode is real: the cache sits in front of auth and keys only on route. If the first user primes the cache, the second user can get the stored response without ever being checked.
Why that ordering feels harmless in code review
It looks harmless because caching is usually treated as a read optimization, while auth is treated as an access-control step. That split hides the fact that cached reads can have the same business effect as a fresh authorization decision.
Code review tends to miss this when:
- the cache wrapper is generic
- the route is “read-only”
- the response looks innocuous in a single-user test
- the team assumes session state will prevent reuse automatically
- the UI already hides privileged controls
That last point is the trap. UI gating tells you what the page should show, not what the server will actually return if the response is reused.
A safe reproduction of the failure mode
Minimal app shape and threat model
A useful reproduction does not need real secrets or a real SaaS app. You only need:
- two identities with different access
- one shared route that returns personalized content
- one cache that ignores identity
- one authorization check that should block the lower-privileged user
The threat model is simple: user A has access, user B does not. The question is whether user B can still receive user A’s response after cache reuse.
Repro steps with two users and one shared route
I like to test this with a small local app and two cookie jars.
1. Start the app
node server.js
Assume it listens on http://127.0.0.1:3000.
2. Request the protected route as user A
curl -i -s \
-H 'Cookie: session=alice' \
http://127.0.0.1:3000/billing
Example output:
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
X-Cache: MISS
Content-Length: 29
billing panel for alice
3. Request the same route as user B
curl -i -s \
-H 'Cookie: session=bob' \
http://127.0.0.1:3000/billing
Example output if the bug exists:
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
X-Cache: HIT
Content-Length: 29
billing panel for alice
That is the failure. The second request should have been denied or at least personalized for bob. Instead, it reused a response tied to alice.
If the app is worse than that, the second user may get a body that exposes data and still sees a 200 OK. If the auth layer is only partially present, you might instead see a stale 200 where a 401 or 403 should have been returned after a role change.
Observed responses, status codes, and headers
The headers matter as much as the body.
A bad pattern usually includes one or more of these:
| Signal | What it suggests |
|---|---|
X-Cache: HIT on a protected route | A stored response was reused |
200 OK for a user that should be denied | Auth did not run, or did not win |
Missing Vary: Cookie or Vary: Authorization | Shared cache key may be too broad |
Cache-Control: public on personalized content | The response is allowed to be shared |
| Identical body for different identities | Response reuse crossed a trust boundary |
If you see the same authenticated body being served to two different sessions, the issue is not theoretical. That is direct evidence of cross-identity reuse.
Where the authorization check gets skipped
Middleware order and control flow
The bug usually lives in control flow, not in the authorization code itself.
Two common patterns cause trouble:
- cache middleware wraps the request before auth middleware
- the handler stores a response and replays it without re-checking the current user
In Express, Koa, or similar frameworks, middleware order is the whole story. If the cache returns a response and does not call next(), downstream auth never runs.
The safe order is not “cache first because it is faster.” The safe order is “verify access, then decide whether a response can be reused.”
Cache keys that ignore identity or role
The simplest broken cache key is path-only:
const key = req.method + ":" + req.originalUrl;
That is fine only for truly public content.
For protected content, the key must reflect the dimensions that change the response. Depending on the route, that might include:
- authenticated user id
- role or entitlement
- tenant id
- locale
- A/B variant
- token scope
The catch is that adding identity to the key can explode cache cardinality. That is not a reason to ignore identity. It is a reason to ask whether the route should be cached at all, or whether it should be cached only in a private browser cache.
UI-only gating versus backend enforcement
This is where many reviews go off the rails.
The frontend may hide buttons, blank out premium fields, or redirect unauthenticated users. That helps with UX, but it does not enforce anything. If the backend returns the same HTML or JSON to every session because the cache hit happens first, the UI never gets the chance to apply its gate.
I would phrase the rule this way: if the backend would object when asked directly, then the cache must not be able to answer on its behalf.
How shared caches make the bug worse
Browser cache versus CDN versus application cache
Not all caches behave the same, and that distinction matters.
| Cache layer | Scope | Typical risk |
|---|---|---|
| Browser cache | Single user agent | Stale private data on the same device |
| CDN / reverse proxy | Shared across users | Cross-user exposure if headers are wrong |
| Application cache | Shared in-process or distributed | Authorization bypass if middleware order is wrong |
A browser cache can still cause stale authorization problems, especially after logout or role changes on the same device. But the more serious regression is usually at the CDN or application layer, where one user can receive another user’s response.
Response reuse across sessions
Shared caches become dangerous when they reuse a response that depends on any of these:
- current session
- current tenant
- current permission set
- one-time state like “has completed setup”
A response can be “read-only” and still be unsafe to share. For example, a dashboard page that shows account name, billing tier, or internal project counts is not harmless if it is reused across identities.
The same is true for APIs. If /api/me or /api/permissions gets cached without a user-bound key, a later session can inherit the first user’s state.
The role of cache headers like Vary, private, and no-store
This is where the HTTP details matter.
Cache-Control: privatesays shared caches should not store the response, but a browser may.Cache-Control: no-storeis the stricter choice when you do not want the response stored at all.Varytells caches which request headers affect the response.
For personalized authenticated content, a default-safe policy is usually Cache-Control: no-store. If you intentionally cache something that varies by user or tenant, then the response needs a cache key strategy that actually matches that variation. Vary: Cookie or Vary: Authorization may be part of that, but I would treat it as a specialized design, not a default.
The mistake I see often is a team adding cache headers for performance without deciding whether the response is shareable. If the answer is “no,” the headers should make that obvious.
Confirmed facts and reasonable inferences
What the trace proves
If you can show this sequence, the core bug is confirmed:
- user A requests a protected route
- response is generated and cached
- user B requests the same route with different auth state
- the cache returns the stored response
- the request does not reach the auth check again
That proves the cache is participating in authorization in a way it should not.
A minimal local trace might look like this:
[cache] MISS GET /billing
[auth] session=alice => allowed
[handler] rendered billing for alice
[cache] HIT GET /billing
If the second request stops at the cache, that is not an inference. That is the control flow you observed.
What is likely but not directly proven
Some consequences are likely, but you should not overstate them without more tests:
- that the reused body contains sensitive data rather than just stale UI
- that the same behavior happens across a CDN, not just in a local app cache
- that logout or role revocation is bypassed in every path
- that all browser types behave the same way with the headers involved
Those are all plausible, but each one needs a specific test.
What still needs testing before you call it a full leak
Before I would describe the issue as a full data leak, I would test:
- whether the cached body includes user-specific values
- whether the response is cached across different browsers or just one client
- whether the issue survives a hard refresh or only a soft navigation
- whether
Cache-ControlorVaryheaders change the behavior - whether the backend still blocks a direct uncached request
That last one matters. Sometimes the cache hides a backend authorization weakness. Other times the backend is fine and only the cache path is broken. The remediation differs.
Impact analysis
Unauthorized reads and stale privilege exposure
The clearest impact is unauthorized reads.
Examples include:
- another user’s dashboard snapshot
- a prior tenant’s project list
- billing metadata
- internal counters or entitlement flags
- a page that still shows premium controls after downgrade
Stale privilege exposure is especially common after role changes. A user loses access, but a cached response still presents the old privileges for a short window. Even if the backend would deny a fresh request, the stale cache response can keep the old state visible.
Functionality abuse without direct data disclosure
Not every case leaks raw data. Some responses are dangerous because they let the user keep doing things they should no longer be allowed to do.
Examples:
- a stale page still shows an admin action button
- a cached JSON payload contains an identifier that can be reused elsewhere
- a premium feature flag remains true after access is revoked
- a workflow step appears complete when it should not
That is still a security issue. It may not be a dramatic disclosure, but it can still drive unauthorized actions or confusing state transitions.
Why this becomes a security regression, not just a performance bug
I would not accept “it only affects cached content” as a defense.
If the response influences access, state, or user decisions, then cache behavior is part of security behavior. A performance optimization that changes who sees what is not an optimization. It is a trust regression.
My position is blunt here: if the route depends on identity or authorization, the default should be to avoid shared caching unless you have explicitly designed for it.
Defensive fixes that actually hold
Run authorization before cache reuse for protected content
The first rule is simple: do not let cache hits bypass auth.
In middleware terms, auth must run before any response is served from a shared cache. If the response depends on identity, the security check should own the decision, and the cache can only reuse something after that decision is made.
A safer pattern is:
- authenticate the request
- authorize the request
- render the response
- decide whether the response may be cached
- store it with the right scope
If you can only implement one fix, this is the one.
Partition cache entries by user, role, or tenant when needed
If you truly need caching for authenticated content, the cache key must match the security boundary.
Depending on the system, that may mean keying by:
- user id
- tenant id
- role
- permission version
- auth scope
Be careful here. Keying by user can work, but it can also create operational cost and memory pressure. That is why I prefer to ask a different question first: does this route need a shared cache at all, or would a private browser cache be enough?
If the content is personalized, a shared cache is often the wrong tool.
Mark personalized responses as private and test header behavior
For responses that should not be shared, set headers deliberately and verify them in tests.
Commonly useful defaults are:
Cache-Control: no-store
or, for cases where browser caching is acceptable but shared caching is not:
Cache-Control: private, max-age=60
Do not stop at setting headers. Test how your proxy, CDN, and framework actually interpret them. Some systems normalize, strip, or override headers in ways that surprise teams later.
Keep security decisions in the backend, not in rendered state
Never rely on the frontend to enforce access.
If the rendered HTML says “hide this button,” that is a presentation choice. If the backend says “this user may not perform the action,” that is an authorization decision. The cache must never be able to replace the latter with the former.
The backend should still validate every sensitive action, even if the UI was previously rendered with the right state. That is how you survive stale cache entries, role changes, and client-side tampering.
How to verify the fix
Regression tests for multiple identities
Write tests that prove two different identities get different outcomes.
A good test matrix includes:
- authenticated user with access
- authenticated user without access
- same path, different cookies or tokens
- cache miss followed by cache hit
- role upgrade and role downgrade scenarios
A small integration test can catch the bug early:
test("protected route is not reused across sessions", async () => {
const alice = await request(app)
.get("/billing")
.set("Cookie", "session=alice");
expect(alice.status).toBe(200);
expect(alice.text).toContain("alice");
const bob = await request(app)
.get("/billing")
.set("Cookie", "session=bob");
expect(bob.status).toBe(403);
});
The exact assertions depend on your app, but the point is constant: the second identity must not inherit the first identity’s response.
Integration checks for cache headers and response variance
I would also test the headers directly.
Useful checks:
- does the route emit
Cache-Controlthat matches its sensitivity? - does the response vary on the headers that actually change the body?
- does a proxy or CDN preserve those headers?
- does a cached authenticated response ever appear in shared infrastructure logs as reusable?
One practical pattern is to compare response hashes under different identities and make sure the differences are expected, not accidental.
Negative tests for stale sessions and revoked access
The nastiest bugs show up when access changes after the first request.
Test these cases:
- user logs out, then revisits a cached page
- role is revoked, then the old page is requested again
- tenant membership changes
- premium plan is downgraded
- session is reissued with a new scope
If the page still renders old privileges, the cache or a downstream store is holding onto state too long.
What I would ship
Default-safe cache policy for authenticated routes
My baseline policy is boring on purpose:
- authenticated routes are
no-storeunless there is a very specific reason not to be - shared caches do not serve personalized content
- any route that varies by identity is tested with at least two identities
- cache middleware is placed after auth, not before it
If performance becomes a concern, I would optimize after proving the route is safe to share. I would not start by assuming it is safe.
Logging and monitoring for suspicious cache hits
I would also log enough to spot the weird cases:
- cache hit on a route that should be private
- cache hit serving a response generated for a different identity
- unexpected
200on routes that should return401or403 - response headers that indicate shared reuse on personalized content
That kind of logging is useful because this bug often looks fine in normal traffic and only appears when state changes or users collide on the same path.
Conclusion
Premature caching is not just “auth, but faster.” It is a way to accidentally move authorization behind a reuse layer that may not understand identity at all.
My technical position is straightforward: if a response depends on who the user is, then a cache that ignores identity can become an authorization bug. The safest fix is to run auth first, keep personalized responses out of shared caches by default, and prove the behavior with tests that involve more than one user.
The bug is subtle in code review and obvious in production traces. That is exactly why it deserves to be treated as a security issue from the start.


