Caching Sensitive Responses

A cache that does not understand who a response belongs to will serve one user's data to another.

Caching an authenticated response is a correctness problem before it is a security one, and when it goes wrong the failure is severe: one user receives another user's data, intermittently, with no attack involved.

Where the copies live

Between your handler and the user there may be an application cache, a CDN, a reverse proxy, a corporate proxy and the browser. Each will cache what it is permitted to cache, and each decides independently.

The headers that matter

  • Cache-Control: no-store — do not store this anywhere. The right default for authenticated API responses.
  • Cache-Control: private — the browser may cache it; shared caches may not.
  • Vary — the response differs by these request headers. Missing Vary: Authorization or Vary: Cookie is exactly how a shared cache serves the wrong user's data.

Application caches

The same rule in your own code: the cache key must include everything the response depends on — user id, tenant id, role and locale. A key of dashboard:summary is a leak waiting for the next request.

The CDN problem

CDNs cache by URL by default. If an authenticated endpoint sits behind one and does not send the right headers, the first user's response is served to everyone. Configure the CDN to bypass caching for anything with an Authorization header or a session cookie, and treat that as a rule rather than a per-route decision.

Browser history and back button

Without no-store, a page containing personal data remains in the browser cache after logout. On a shared computer, the back button shows it. That is the specific reason no-store is preferred over no-cache for sensitive responses.

Example

Example · javascript
// ❌ The cache key does not include who it is for
const summary = await cache.get('dashboard:summary');

// ❌ Cached by a CDN, keyed on URL alone, served to everyone
res.set('Cache-Control', 'public, max-age=300').json(userInvoices);

// ✅ Everything the response depends on is in the key
const key = `t:${tenantId}:u:${userId}:role:${role}:dashboard`;

// ✅ And the right headers for an authenticated response
res.set('Cache-Control', 'no-store');
res.set('Vary', 'Authorization, Cookie');

When to use it

  • A CDN serves one customer's invoice list to every visitor because an authenticated endpoint returned a public cache header.
  • A dashboard cache keyed without the tenant briefly shows one company's revenue to another.
  • A shared library computer displays a previous user's account page through the back button, until no-store is applied.

More examples

Cache headers by default, opt in deliberately

The no-cache versus no-store distinction is the one most often confused, and it is the difference between a response being revalidated and never written to disk.

Example · javascript
// Deny by default. Authenticated responses are never stored unless a route
// explicitly says otherwise.
app.use('/api', (req, res, next) => {
  res.set('Cache-Control', 'no-store');
  res.set('Vary', 'Authorization, Cookie');
  next();
});

// Opt in per route, with the reasoning visible.
const cacheable = ({ seconds, scope = 'private' }) => (req, res, next) => {
  if (scope === 'public' && (req.get('authorization') || req.get('cookie'))) {
    // A "public" route reached with credentials is a bug — do not cache it.
    logger.warn({ path: req.path }, 'credentialed request to a public route');
    return next();
  }
  res.set('Cache-Control',
    `${scope}, max-age=${seconds}, stale-while-revalidate=${seconds * 2}`);
  next();
};

// Truly public, identical for everyone → a shared cache is fine
app.get('/api/public/plans', cacheable({ seconds: 3600, scope: 'public' }),
  listPlans);

// Per-user, safe in the BROWSER but never in a shared cache
app.get('/api/me/preferences', auth, cacheable({ seconds: 60, scope: 'private' }),
  getPreferences);

// Sensitive → never stored anywhere (inherits no-store from above)
app.get('/api/invoices', auth, listInvoices);
app.get('/api/me', auth, getProfile);

// ── The header combination that actually works ───────────────────────
// Sensitive:
//   Cache-Control: no-store
//   Vary: Authorization, Cookie
//   Pragma: no-cache            (only for ancient HTTP/1.0 proxies)
//
// Per-user, cacheable in the browser:
//   Cache-Control: private, max-age=60
//   Vary: Authorization, Cookie
//
// Genuinely public:
//   Cache-Control: public, max-age=3600
//   Vary: Accept-Encoding
//
// ⚠️ no-cache does NOT mean "do not cache". It means "revalidate before use",
//    so the response is still STORED. For sensitive data you want no-store.

it('never marks an authenticated response public', async () => {
  for (const route of authenticatedRoutes) {
    const res = await request(app).get(route.path)
      .set('Authorization', `Bearer ${token}`);
    expect(res.headers['cache-control']).not.toMatch(/public/);
  }
});

Application cache keys that cannot collide

Including the role in the key is easy to forget and matters after a permission change: without it, a demoted user keeps seeing the cached admin view.

Example · javascript
// A key must include EVERY input the response depends on. Missing one is a
// cross-user leak that reproduces only under specific request ordering.
import { createHash } from 'crypto';

export class ScopedCache {
  #prefix;

  constructor({ tenantId, userId, role, locale = 'en' }) {
    if (!tenantId || !userId) {
      throw new Error('ScopedCache requires tenantId and userId');
    }
    // Everything that can change the response goes in the key.
    this.#prefix = `v2:t:${tenantId}:u:${userId}:r:${role}:l:${locale}:`;
  }

  #key(name, params = {}) {
    // Sort so {a:1,b:2} and {b:2,a:1} produce one key, not two.
    const sorted = Object.keys(params).sort()
      .map((k) => `${k}=${params[k]}`).join('&');
    const hash = createHash('sha256').update(sorted).digest('hex').slice(0, 16);
    return `${this.#prefix}${name}:${hash}`;
  }

  async get(name, params, ttl, produce) {
    const key = this.#key(name, params);
    const hit = await redis.get(key);
    if (hit) return JSON.parse(hit);

    const value = await produce();
    await redis.set(key, JSON.stringify(value), 'EX', ttl);
    return value;
  }

  // Invalidate everything for this user in one operation.
  async invalidateUser() {
    const stream = redis.scanStream({ match: `${this.#prefix}*`, count: 100 });
    for await (const keys of stream) if (keys.length) await redis.del(...keys);
  }
}

// Usage
app.get('/api/dashboard', auth, async (req, res) => {
  const cache = new ScopedCache(req.user);
  res.json(await cache.get('dashboard', { range: req.query.range }, 60,
    () => buildDashboard(req.user)));
});

// ── The details that matter ──────────────────────────────────────────
// 'v2:' prefix    → bump it to invalidate everything after a shape change
// sorted params   → identical requests share one entry
// role in the key → a promoted user does not see the old role's response
// constructor throws → a cache with no scope cannot be created at all

// And the test:
it('never serves one user\'s cached response to another', async () => {
  await request(app).get('/api/dashboard').set('Authorization', `Bearer ${aliceToken}`);
  const bob = await request(app).get('/api/dashboard')
    .set('Authorization', `Bearer ${bobToken}`);
  expect(bob.body.userId).toBe(bobUser.id);
});

CDN configuration for a mixed API

The two-user curl comparison is the single most valuable check here — it tests the entire chain end to end rather than any individual configuration file.

Example · bash
# CDNs cache by URL by default. An authenticated endpoint behind one, without
# the right headers, serves the first response to everybody.

# ── Cloudflare page rule / cache rule ────────────────────────────────
# Bypass the cache whenever a credential is present.
#   (http.request.uri.path matches "^/api/"
#    and (any(http.request.headers["authorization"][*] != "")
#         or http.cookie contains "sid="))
#   → Cache Level: Bypass

# Cache only what is genuinely public
#   (http.request.uri.path matches "^/api/public/")
#   → Cache Level: Cache Everything, Edge TTL 1 hour

# ── CloudFront cache policy ──────────────────────────────────────────
# The cache key MUST include the credential headers, or responses collide.
{
  "CachePolicyConfig": {
    "Name": "api-authenticated",
    "DefaultTTL": 0, "MinTTL": 0, "MaxTTL": 0,
    "ParametersInCacheKeyAndForwardedToOrigin": {
      "HeadersConfig": {
        "HeaderBehavior": "whitelist",
        "Headers": { "Items": ["Authorization"], "Quantity": 1 }
      },
      "CookiesConfig": {
        "CookieBehavior": "whitelist",
        "Cookies": { "Items": ["sid"], "Quantity": 1 }
      },
      "QueryStringsConfig": { "QueryStringBehavior": "all" }
    }
  }
}

# ── nginx as a caching proxy ─────────────────────────────────────────
proxy_cache_bypass $http_authorization $cookie_sid;   # do not SERVE from cache
proxy_no_cache     $http_authorization $cookie_sid;   # do not STORE in cache
proxy_cache_key    "$scheme$request_method$host$request_uri";

# ── Verify it, from outside ──────────────────────────────────────────
# 1. An authenticated response must never be cached
curl -sI https://dfg.com/api/invoices -H "Authorization: Bearer $TOKEN" \
  | grep -iE 'cache-control|cf-cache-status|x-cache|age'
# expect: Cache-Control: no-store, and a MISS/BYPASS status

# 2. The decisive test — two users, same URL
A=$(curl -s https://dfg.com/api/me -H "Authorization: Bearer $ALICE" | jq -r .id)
B=$(curl -s https://dfg.com/api/me -H "Authorization: Bearer $BOB"   | jq -r .id)
[ "$A" = "$B" ] && echo "CRITICAL: cross-user cache leak" || echo "ok"

# Run test 2 after every CDN configuration change. It takes two seconds and it
# is the only one that proves the whole chain is correct.

Discussion

  • Be the first to comment on this lesson.