永远不应该缓存的内容
缓存是前端性能优化中最强大的工具之一。用对了,它能消除冗余的网络请求、减轻服务器负载,让你的应用响应如飞。用错了,它会泄露私密数据、返回过期的认证内容,或者让用户陷入无法自行解脱的页面异常状态。
规则其实比大多数文章说的要简单:静态的、带版本号的公共资源几乎总是可以放心地积极缓存。其他所有内容默认应视为不安全。
在讨论什么不应该缓存之前,有必要先厘清缓存发生在哪里,因为不同层级的缓存行为差异显著。
核心要点
- 各缓存层(HTTP、CDN、Service Worker、bfcache、Web Storage)具有不同的作用域和安全特性,将它们混为一谈会引发真实的 bug。
no-store完全禁止存储响应,而no-cache仅要求在复用前重新验证。- 经过认证的或用户特定的响应必须使用
Cache-Control: private, no-store,以防止共享缓存在用户之间泄露数据。 - 切勿将 JWT、刷新令牌或 API 密钥存储在
localStorage或IndexedDB中,应优先使用HttpOnlyCookie。 - 只要过期或共享的副本可能引发安全或正确性问题,就应默认使用
no-store。
缓存层并不可以互换使用
大多数缓存错误,都源于将以下这些机制混为一谈:
- HTTP/浏览器缓存 — 通过
Cache-Control响应头按照 RFC 9111 进行控制。在多个标签页之间共享,跨会话持久存在。 - CDN/共享缓存 — 位于用户与源站之间。Cloudflare CDN 等服务会为所有用户缓存响应,而非仅针对某一个用户。
- Service Worker Cache API — 一种完全由 JavaScript 控制的可编程缓存,持久存在直到被显式清除。
- bfcache(前进/后退缓存) — 浏览器对完整页面的内存快照,用于导航回退时恢复页面。与 HTTP 缓存相互独立。
localStorage/sessionStorage— 键值对存储。没有过期管理机制,JavaScript 可完全访问。IndexedDB— 结构化的持久存储。与localStorage具有相同的 XSS 攻击面。
每种机制的持久性、作用域和安全特性各不相同,混淆使用会导致真实的 bug。
关于 Cache-Control 语义的说明
这个区别在业界被广泛误解,有必要明确说明:
no-store表示缓存完全不得存储该响应。no-cache并不意味着”不缓存”。它的含义是:缓存的副本在复用前必须向服务器重新验证。
如果你希望响应完全不被任何地方存储,请使用 no-store。如果你希望每次使用时都能保证内容的新鲜度,同时又想保留条件请求(ETag、304)带来的效率优势,请使用 no-cache。
永远不应该缓存的内容
经过认证的 API 响应和用户特定的 HTML
任何因用户身份而变化的响应——仪表盘 HTML、账户页面、包含个人资料数据的 API 响应——都不应该被存储在共享缓存中。常见的做法是:
Cache-Control: private, no-store
no-store 阻止普通 HTTP 缓存存储该响应,而 private 则明确告知 CDN 等共享缓存不得为多个用户缓存此响应。
若缺少 private,Cloudflare 等 CDN 可能会缓存为用户 A 返回的响应,并将其提供给用户 B。这已在现实中引发过数据泄露事件。
Service Worker 中任何需要认证的内容
Service Worker Cache API 本质上是一个可编程的网络代理。Service Worker 会拦截其作用域内的每一个 fetch 请求。如果你不加区分地缓存经过认证的 API 响应或用户特定的 HTML,这些数据将持久存储在 Cache API 存储中——可能跨会话存在,并且无论登录状态如何,Service Worker 都可以访问。
// ❌ 不要这样做
self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request).then(response => {
return caches.open('v1').then(cache => {
cache.put(event.request, response.clone());
return response;
});
})
);
});
这种模式会不加区分地缓存所有内容。应当明确排除需要认证的路由:
// ✅ 只缓存公共的、带版本号的静态资源
const CACHEABLE = ['/shell.html', '/app-abc123.js', '/styles-f9c.css'];
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
if (url.origin !== self.location.origin) return; // 忽略跨域请求
if (!CACHEABLE.includes(url.pathname)) return; // 直接走网络请求
event.respondWith(
caches.match(event.request).then(r => r || fetch(event.request))
);
});
localStorage 或 IndexedDB 中的敏感凭证
OWASP HTML5 安全备忘单明确指出:将 JWT、刷新令牌、API 密钥或密码重置令牌存储在 localStorage 或 IndexedDB 中存在风险,因为你域名上的任何 XSS 漏洞都可以读取这些数据。这些存储机制没有内置的过期管理,没有类似 HttpOnly 的保护机制,也无法与注入的脚本相互隔离。
在条件允许的情况下,请使用带有 HttpOnly、Secure、SameSite 属性的 Cookie 来存储令牌。如果你必须使用 localStorage,请明确意识到你正在承担这一风险。
Discover how at OpenReplay.com.
CDN 层的用户特定响应
一个常见的 Cloudflare CDN 配置错误:在包含用户数据的响应中遗漏了 private 指令,或者 Vary 配置有误。
# ❌ 缺少 private — CDN 可能会将此响应缓存给所有用户
Cache-Control: max-age=300
Content-Type: application/json
# ✅ 正确做法
Cache-Control: private, no-store
如果你的响应确实需要根据某个请求头(如 Accept-Language 或 Authorization)而变化,就必须包含正确的 Vary 响应头。否则,CDN 可能会将缓存的法语响应提供给英语用户,更严重的情况是将缓存的已认证响应提供给未认证的用户。
此外还需警惕通过未纳入缓存键的参数进行缓存投毒:如果你的 CDN 基于 URL 进行缓存,但你的应用读取了未经验证的查询参数或请求头来构造响应,攻击者就可以通过精心构造的请求污染缓存。
不应在 bfcache 中恢复的页面
在近期版本中,基于 Chromium 的浏览器已经开始更积极地对发送了 Cache-Control: no-store 的页面使用 bfcache。这一行为因浏览器而异,在 Chrome、Firefox 和 Safari 之间并不完全一致。caniuse 显示现代浏览器对 bfcache 的支持已相当广泛,但 no-store 与 bfcache 的交互方式在不同浏览器引擎和版本之间存在差异。
如果某些页面在恢复过期内存快照时确实存在安全隐患——例如显示一次性支付确认的页面,或具有会话敏感状态的页面——请使用 pageshow 事件来检测 bfcache 恢复,并执行重新加载或重新验证:
window.addEventListener('pageshow', event => {
if (event.persisted) {
// 页面从 bfcache 中恢复
window.location.reload();
}
});
不要假设 no-store 能在所有浏览器中普遍禁用 bfcache。
默认思维模型
如果你不确定某个内容是否应该被缓存,请问自己:提供这份内容的过期副本或共享副本,是否会引发安全或正确性问题? 如果答案是肯定的,就默认使用 Cache-Control: no-store,再根据需要逐步放开。
带有内容哈希 URL 的静态资源——你的 JavaScript 包、CSS 文件、图片——可以安全地使用 max-age=31536000, immutable 缓存一年。而所有与用户会话、认证状态或敏感操作相关的内容则不应如此处理。
结语
积极缓存是一种特性,而对错误内容的意外缓存则是一个安全漏洞。关键在于:清楚地知道你在针对哪个缓存层、每个 Cache-Control 指令实际提供了什么保证,以及哪些类别的数据永远不应脱离源站的严格管控。将所有与用户、会话或敏感凭证相关的内容默认视为不可缓存,把积极缓存留给它本来所适用的场景——静态的、带版本号的资源。
常见问题
这在技术上是可行的,但不推荐。你域名上的任何 XSS 漏洞都会让攻击者完全读取 localStorage 的内容,从而窃取令牌并冒充用户。带有 HttpOnly、Secure、SameSite 属性的 Cookie 更为安全,因为 JavaScript 无法直接读取它们。如果你必须使用 localStorage,则意味着你在接受 XSS 风险,并应在内容安全策略(CSP)和输入过滤上投入大量精力。
no-store 禁止普通 HTTP 缓存存储响应以供复用。no-cache 允许存储响应,但要求在每次复用前向源站重新验证,通常通过 ETag 或 Last-Modified 条件请求来实现。对敏感数据使用 no-store,当你希望保证内容新鲜度同时又能受益于 304 Not Modified 响应时,使用 no-cache。
通常是因为响应缺少 private 指令,或者 Vary 响应头配置不正确。没有 private,共享缓存会将响应视为对所有人可缓存的内容。没有针对 Authorization 或 Cookie 等请求头的 Vary 配置,CDN 在生成缓存键时会忽略这些请求头,从而可能将用户 A 的响应提供给用户 B。对于用户特定的响应,务必发送 Cache-Control: private, no-store。
目前没有可靠的跨浏览器响应头能够禁用 bfcache。Cache-Control: no-store 在某些浏览器中有效,但并不一致。可靠的做法是监听 pageshow 事件并检查 event.persisted。如果其值为 true,说明页面是从 bfcache 中恢复的,此时你可以强制重新加载或重新获取敏感状态。这对于支付确认页面和退出登录后的页面尤为重要。
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.