← Blog

Claude skill HTTP caching security

MCP skills that serve their dashboard, API, or OAuth flows via a CDN or reverse proxy — Cloudflare, Caddy with reverse_proxy, nginx, or any edge cache — are vulnerable to caching misconfigurations that cause one user’s personal data to be served to another user, sensitive security tokens to be persisted in browser or CDN cache, and resource-existence probes to succeed after deletion. SA-SKL-CACHE covers four HTTP caching security patterns: Cache-Control: public on authenticated API responses causing cross-user cache poisoning, missing Vary headers causing a proxy to serve cached responses without considering the requesting user’s identity, missing Cache-Control: no-store on sensitive pages (OAuth callbacks, 2FA pages) causing browser back-button token exposure, and ETag/Last-Modified headers on responses that must not be conditionally reused after deletion or access revocation.

By SkillAudit  |  3 Nov 2026

HTTP caching vulnerabilities in skill servers differ from typical web application caching bugs because skill deployments often involve a shared reverse proxy (the factory VPS running Caddy for all agent sites) where caching directives from one skill can interact with another’s. Additionally, MCP skills are frequently tested in a browser development context where the developer is the only user, making cross-user caching bugs invisible until the skill receives real traffic from multiple accounts. The four SA-SKL-CACHE patterns are all silent failures: no error is thrown, no access denied response is returned; a different user simply receives the wrong response, or a sensitive token persists longer than intended.

SA-SKL-CACHE-001: Cache-Control: public on authenticated API response causes cross-user cache poisoning

An API endpoint that returns user-specific data — audit history, subscription status, personal settings — with Cache-Control: public or no Cache-Control header allows a CDN or shared proxy to cache the response keyed by URL. When a second user makes the same URL request, the CDN serves the cached response from the first user. The URL GET /api/audits?limit=10 looks the same to a cache for user A and user B. Without a Cache-Control: private directive, the CDN caches the first user’s audit list and serves it to the second user. In skills where the audit list contains repository URLs (which may be private), API keys, or security findings, this is a cross-user data leak. The fix is to set Cache-Control: private, no-cache on all authenticated API responses.

// VULNERABLE: authenticated API response with no Cache-Control header
import express from 'express';

app.get('/api/audits', authenticateMiddleware, async (req, res) => {
  const audits = await db.all(
    'SELECT * FROM audits WHERE user_id = ? ORDER BY created_at DESC LIMIT 10',
    [req.user.id]
  );

  // BUG: no Cache-Control header set
  // Express default: no Cache-Control header at all
  // Caddy/nginx/CDN default when no Cache-Control: may cache based on URL
  // User A requests /api/audits → CDN caches response with user A's data
  // User B requests /api/audits → CDN serves user A's cached audit list
  res.json({ audits });
});

// ALSO VULNERABLE: explicit public cache (e.g., copied from a static asset config)
app.get('/api/user', authenticateMiddleware, async (req, res) => {
  const user = await db.get('SELECT * FROM users WHERE id = ?', [req.user.id]);
  res.set('Cache-Control', 'public, max-age=300');  // BUG: public + 5min TTL
  res.json({ user });
});

// SECURE: explicit Cache-Control: private on authenticated responses
// Middleware to set correct caching headers on all authenticated routes
function noPublicCache(req, res, next) {
  // private: CDN/proxy must NOT cache; only browser can cache for the current user
  // no-cache: browser must revalidate with server before using cached copy
  // The combination ensures fresh data AND prevents cross-user serving
  res.set('Cache-Control', 'private, no-cache');
  next();
}

// Apply globally to all authenticated routes
app.use('/api', authenticateMiddleware, noPublicCache);

app.get('/api/audits', async (req, res) => {
  const audits = await db.all(
    'SELECT * FROM audits WHERE user_id = ? ORDER BY created_at DESC LIMIT 10',
    [req.user.id]
  );
  res.json({ audits });
});

// For public, non-personalized API responses (e.g., GET /api/public-audits):
// Explicit public cache is fine ONLY when response is truly the same for all users
app.get('/api/public-audits', async (req, res) => {
  const audits = await db.all(
    'SELECT id, repo_url, score FROM audits WHERE public = 1 LIMIT 20'
  );
  // Explicit public cache — safe because response is identical for all users
  res.set('Cache-Control', 'public, max-age=300, s-maxage=600');
  res.json({ audits });
});

// DETECTION (SkillAudit):
// authenticated route (after authenticateMiddleware) with:
// - no Cache-Control header
// - Cache-Control: public
const publicCacheOnAuth = searchAST(
  'route with authenticateMiddleware AND (no Cache-Control OR Cache-Control: public)'
);
if (publicCacheOnAuth) → HIGH  // SA-SKL-CACHE-001

No Cache-Control header is as dangerous as public: CDNs and reverse proxies apply their own heuristic caching rules when no Cache-Control directive is present. Cloudflare caches responses based on file extension heuristics; Caddy’s cache plugin caches responses without explicit no-store; Varnish caches by default. Set Cache-Control: private, no-cache explicitly on every authenticated API response, even if you believe no cache is in the path today. A cache may be added to the infrastructure later.

HIGH
SA-SKL-CACHE-001 — Cache-Control public on authenticated response: authenticated API endpoint (GET /api/audits, GET /api/user) returns user-specific data without Cache-Control: private; CDN caches response keyed by URL; second user receives first user’s personal data. Detection: authenticated route handler without res.set('Cache-Control', 'private') → HIGH.

SA-SKL-CACHE-002: Vary header missing on personalized responses causes proxy cache collision

The Vary response header tells caches which request headers must match for a cached response to be served to a subsequent request. Without Vary: Authorization (or Vary: Cookie for session-based auth), a proxy serves the same cached response to all requests with the same URL regardless of which user is authenticated. This differs from CACHE-001 in that CACHE-002 affects caches that do not apply Cache-Control: private correctly — for example, a shared in-process cache (an LRU cache within the skill server itself) that keys by URL without including the user ID, or an upstream service that respects Cache-Control: public but not Vary: Authorization. The fix is to include Vary: Authorization, Cookie on all responses where the content varies by the authenticated user.

// VULNERABLE: personalized response without Vary header
app.get('/api/profile', authenticateMiddleware, async (req, res) => {
  const profile = await db.get('SELECT * FROM users WHERE id = ?', [req.user.id]);

  // BUG: no Vary header — proxy does not know that this response varies by user
  // Some caches treat Authorization as a cache-busting key; others don't
  // In-process LRU cache keyed by URL (common mistake):
  const cached = lruCache.get(req.url);  // BUG: keyed by URL only, not user
  if (cached) return res.json(cached);

  res.set('Cache-Control', 'private, no-cache');
  // Still missing Vary: Authorization — some intermediate caches ignore Cache-Control: private
  res.json({ profile });
  lruCache.set(req.url, { profile });  // BUG: caches for all users under same URL key
});

// SECURE: Vary header + correct in-process cache key
app.get('/api/profile', authenticateMiddleware, async (req, res) => {
  // Always include Vary on personalized responses
  res.set('Vary', 'Authorization, Cookie');
  res.set('Cache-Control', 'private, no-cache');

  // In-process cache (if used): key by BOTH URL and user ID
  const cacheKey = `${req.url}:${req.user.id}`;
  const cached = lruCache.get(cacheKey);
  if (cached) {
    return res.json(cached);
  }

  const profile = await db.get('SELECT * FROM users WHERE id = ?', [req.user.id]);
  lruCache.set(cacheKey, { profile });
  res.json({ profile });
});

// For shared caches (CDNs) where public caching is desired for some routes:
// Use URL structure that embeds the user ID to create naturally separate cache entries:
// GET /api/users/:userId/profile → keyed by userId in URL, safe to cache publicly
// vs:
// GET /api/profile → keyed by URL only, MUST NOT be cached publicly

// If using Redis for distributed caching:
// BAD:  redis.set(`profile:${req.url}`, JSON.stringify(profile))
// GOOD: redis.set(`profile:${req.user.id}`, JSON.stringify(profile))

// DETECTION (SkillAudit):
// authenticated route with in-process cache keyed by req.url WITHOUT req.user.id
const missingVary = searchAST(
  'lruCache.get(req.url) OR cache.get(req.url) WITHOUT user_id in cache key'
);
if (missingVary) → HIGH  // SA-SKL-CACHE-002

In-process LRU caches are the most common source of CACHE-002: developers add an in-memory LRU cache to speed up database queries and key it by the request URL (the most natural key). If the same URL serves different users different data, the first user’s response is served to all subsequent users until the cache entry expires. Always include the authenticated user’s ID in the cache key for any personalized endpoint.

HIGH
SA-SKL-CACHE-002 — Missing Vary header: personalized API response without Vary: Authorization, Cookie; in-process or proxy cache keyed by URL without user identity; cached response for user A served to user B. Detection: lruCache.get(req.url) on authenticated route without user ID in cache key → HIGH.

SA-SKL-CACHE-003: Cache-Control: no-store missing on OAuth callback page persists security tokens in browser cache

OAuth callback pages (/auth/callback?code=xxx&state=yyy) and 2FA confirmation pages contain security-sensitive values in the URL or response body: the OAuth authorization code, the PKCE verifier, the 2FA confirmation token. Without Cache-Control: no-store, the browser stores the response in its HTTP cache. The user’s OAuth code is then available via the browser’s Back button, via history.back() navigation, or via another page on the same site that causes the browser to load the cached response. An attacker who can cause the victim to navigate back to the OAuth callback URL (via a redirect or JavaScript navigation) can extract the still-valid code from the cached response. The fix is to set Cache-Control: no-store on all pages that contain security tokens in their URL or response body.

// VULNERABLE: OAuth callback without no-store
import express from 'express';

app.get('/auth/callback', async (req, res) => {
  const { code, state } = req.query;

  // code is a one-time OAuth authorization code
  // BUG: no Cache-Control: no-store — browser caches this response
  // User presses Back → browser serves cached callback page with the old code in URL
  // Attacker who redirects user to /auth/callback re-fetches from browser cache
  // Code may have been consumed, but state parameter reveals the session nonce

  await exchangeCodeForTokens(code, state);
  res.redirect('/dashboard');  // BUG: redirect response is also cached without no-store
});

// ALSO VULNERABLE: 2FA confirmation page
app.get('/2fa/confirm', (req, res) => {
  // BUG: renders 2FA form with CSRF token in HTML — cached by browser
  // Back button or cache probe reveals the CSRF token
  res.render('2fa', { csrfToken: req.csrfToken() });
});

// SECURE: no-store on all security-sensitive pages
// Middleware for pages that must never be cached
function noCacheStore(req, res, next) {
  // no-store: browser must not store ANY part of the response
  // This is stronger than no-cache (which allows conditional requests)
  // Use no-store on pages with security tokens; no-cache is insufficient
  res.set('Cache-Control', 'no-store');
  // Also set Pragma: no-cache for HTTP/1.0 compatibility (proxy caches)
  res.set('Pragma', 'no-cache');
  next();
}

app.get('/auth/callback', noCacheStore, async (req, res) => {
  const { code, state } = req.query;

  // Validate state parameter before using code
  if (!validateState(state, req.session)) {
    return res.status(400).json({ error: 'Invalid state parameter' });
  }

  const tokens = await exchangeCodeForTokens(code, state);

  // Store tokens in session (not in URL or cookie with loose settings)
  req.session.accessToken = tokens.accessToken;

  // Redirect AFTER clearing sensitive query params from URL
  // Use history.replaceState equivalent: redirect to clean URL
  res.redirect(303, '/dashboard');
  // 303 See Other: browser replaces the callback URL in history with /dashboard
  // User clicking Back lands on the page before the OAuth flow, not the callback
});

app.get('/2fa/confirm', noCacheStore, (req, res) => {
  res.render('2fa', { csrfToken: req.csrfToken() });
});

// Apply no-store to all auth-related routes:
app.use('/auth/', noCacheStore);
app.use('/2fa/', noCacheStore);

// DETECTION (SkillAudit):
// GET /auth/callback or /oauth/callback route WITHOUT Cache-Control: no-store
const noStoreAbsent = searchAST(
  'app.get("/auth/callback" | "/oauth/callback" | "/2fa/*") ' +
  'WITHOUT res.set("Cache-Control", "no-store")'
);
if (noStoreAbsent) → HIGH  // SA-SKL-CACHE-003

no-cache is not no-store: Cache-Control: no-cache tells the browser it may store the response but must revalidate it before using it. The OAuth code is still stored in the cache; the browser just sends a conditional request before using it. Cache-Control: no-store tells the browser it must not store any part of the response. For OAuth callbacks, 2FA pages, and any URL that includes security tokens, use no-store, not no-cache. Also use HTTP 303 redirects from callback pages — the 303 removes the callback URL from browser history, preventing Back-button re-navigation.

HIGH
SA-SKL-CACHE-003 — No-store absent on OAuth callback: GET /auth/callback?code=xxx route without Cache-Control: no-store; browser caches OAuth code URL; Back button or cache probe re-fetches cached callback; OAuth code and state parameter persisted in browser history. Detection: OAuth/2FA route without no-store Cache-Control → HIGH.

SA-SKL-CACHE-004: ETag and Last-Modified on responses that must not be reused after deletion or access revocation

The HTTP conditional request mechanism allows a browser or proxy to ask “has this resource changed since I last fetched it?” via If-None-Match: <etag> or If-Modified-Since: <date>. The server responds with 304 Not Modified (no body) if the resource is unchanged, saving bandwidth. The security issue arises when a resource is deleted or access to it is revoked: if the server sends 304 Not Modified for a resource that has been deleted (because the ETag still matches the deleted content’s hash), the client infers that the resource still exists with the same content. In MCP skills, this allows a previously-authorized user whose access was revoked to confirm that an audit result still exists by sending a conditional GET with their last known ETag — the 304 response proves existence even though the 404 would correctly deny access. The fix is to check access permissions before evaluating conditional headers: if the user is not authorized to access the resource, return 403/404 without evaluating If-None-Match.

// VULNERABLE: conditional GET evaluated before access check
import express from 'express';
import crypto from 'crypto';

app.get('/api/audit/:id', authenticateMiddleware, async (req, res) => {
  const audit = await db.get('SELECT * FROM audits WHERE id = ?', [req.params.id]);

  if (!audit) {
    return res.status(404).json({ error: 'Not found' });
  }

  // BUG: ETag computed and conditional GET evaluated BEFORE ownership check
  const etag = `"${crypto.createHash('md5').update(JSON.stringify(audit)).digest('hex')}"`;
  res.set('ETag', etag);

  const ifNoneMatch = req.headers['if-none-match'];
  if (ifNoneMatch === etag) {
    // BUG: sends 304 even if audit.userId !== req.user.id
    // Attacker (previously had access, now revoked) sends If-None-Match with old ETag
    // Receives 304 → confirms audit still exists AND still has same content
    // Information disclosure: resource existence + content unchanged confirmed
    return res.status(304).end();
  }

  // Ownership check comes AFTER ETag evaluation — too late
  if (audit.userId !== req.user.id) {
    return res.status(403).json({ error: 'Forbidden' });
  }

  res.json({ audit });
});

// SECURE: authorization check BEFORE conditional GET evaluation
app.get('/api/audit/:id', authenticateMiddleware, async (req, res) => {
  const audit = await db.get('SELECT * FROM audits WHERE id = ?', [req.params.id]);

  // Step 1: existence check
  if (!audit) {
    // Return 404 WITHOUT ETag — no information about the resource
    return res.status(404).json({ error: 'Not found' });
  }

  // Step 2: authorization check BEFORE ETag evaluation
  if (audit.userId !== req.user.id && !req.user.isAdmin) {
    // Return 403 WITHOUT ETag — attacker learns nothing about resource existence
    // (or return 404 to avoid leaking existence via status code difference)
    return res.status(403).json({ error: 'Forbidden' });
  }

  // Step 3: now safe to evaluate conditional request headers
  const etag = `"${crypto.createHash('sha256').update(JSON.stringify(audit)).digest('hex').slice(0, 16)}"`;
  res.set('ETag', etag);
  res.set('Last-Modified', new Date(audit.updatedAt).toUTCString());

  const ifNoneMatch = req.headers['if-none-match'];
  if (ifNoneMatch === etag) {
    // Only reaches here if user is authorized — safe to send 304
    return res.status(304).end();
  }

  // Also: for resources that must never be conditionally reused after any change,
  // omit ETag/Last-Modified entirely:
  // - Audit results that include real-time security findings (always fresh)
  // - Access tokens, session data, payment status
  // Just don't set the ETag header — browsers and proxies won't send conditional requests

  res.json({ audit });
});

// DETECTION (SkillAudit):
// res.set('ETag', *) or res.set('Last-Modified', *) BEFORE ownership/auth check
const etagBeforeAuth = searchAST(
  'res.set("ETag" | "Last-Modified", *) BEFORE authorization check in same handler'
);
if (etagBeforeAuth) → MEDIUM  // SA-SKL-CACHE-004

304 responses must respect the same access controls as 200 responses: the 304 response body is empty, but it implicitly confirms that the resource exists and is unchanged. An attacker who previously had access to a resource and now has their access revoked can probe resource existence by sending conditional GETs with their last known ETag — a 304 confirms existence even though a 403/404 would be returned for a normal GET. Always evaluate authorization before evaluating conditional request headers. For highly sensitive resources (audit results with security findings), simply omit ETag and Last-Modified to prevent conditional caching entirely.

MEDIUM
SA-SKL-CACHE-004 — ETag evaluated before authorization: res.set('ETag', etag) and conditional GET check before ownership check; previously-authorized user sends If-None-Match with old ETag; receives 304 confirming resource still exists and content unchanged; access revocation not reflected in conditional response. Detection: ETag set or If-None-Match evaluated before authorization check → MEDIUM.

Remediation summary

FindingSeverityFix
CACHE-001: Public cache on authenticated responseHIGHSet Cache-Control: private, no-cache on all authenticated API responses via middleware applied to app.use('/api', authenticateMiddleware, noPublicCache)
CACHE-002: Missing Vary headerHIGHSet Vary: Authorization, Cookie on personalized responses; key all in-process caches by userId + URL, never URL alone; use user ID in Redis cache keys
CACHE-003: Missing no-store on OAuth callbackHIGHApply Cache-Control: no-store + Pragma: no-cache to all /auth/ and /2fa/ routes; use HTTP 303 redirect from callback to remove callback URL from browser history
CACHE-004: ETag before authorizationMEDIUMPerform existence and authorization checks before evaluating If-None-Match/If-Modified-Since; omit ETag/Last-Modified from highly sensitive responses where conditional caching should never apply

SkillAudit detects all four SA-SKL-CACHE-001 through CACHE-004 HTTP caching security patterns through static analysis of Express route handlers, response header configuration, and conditional request evaluation order. For related HTTP security patterns in MCP skills, see SA-SKL-SEC security headers audit and SA-SKL-AUT OAuth flow security. See the complete SA-SKL attack surface reference for the full vulnerability taxonomy.

Scan your Claude skill for HTTP caching vulnerabilities

SkillAudit detects missing Cache-Control directives, absent Vary headers, no-store omissions on sensitive pages, and ETag authorization ordering issues across the full SA-SKL-CACHE family in 60 seconds.

Get your free audit