JWT 应该存放在浏览器的哪里?localStorage、cookie 与 XSS/CSRF 的取舍
把 JWT 存在浏览器的哪里,常常被当成“哪个更安全”的排名来问,可它并不是排名。localStorage、cookie、内存,各自对两个问题给出不同答案——JavaScript 能不能读到 token,浏览器会不会自动带上它——而这两个问题正对应 XSS 与 CSRF。本文逐一拆解这几种存法、业界最终收敛的方案,以及为什么防住 XSS 比任何存储选择都更要紧。
本系列前两篇讲的都是服务端:怎么读一个 token,以及怎么正确验它、不被它骗到。这一篇转到浏览器。当你的单页应用(SPA)拿到一个 JWT,它总得把 token 放在某个地方,而这个“放哪”的问题,是 Web 认证里争得最凶的话题之一——而且通常还争错了方向。
争论往往把答案当成一份排名:仿佛某个存储位置天生就比别的“更安全”。其实不是。每种存法,都由它怎么回答两个互相独立的问题来定义:页面上的 JavaScript 能不能读到 token,以及浏览器会不会自动把它带在请求上。第一个问题关乎跨站脚本(XSS),第二个关乎跨站请求伪造(CSRF)。每种存储对这两问的答案都不一样,所以正确的选择取决于你更容易遭哪种攻击——而归根结底,取决于你到底能不能把 XSS 挡在页面之外。最后这一点,正是大多数争论刻意绕开的,所以我们特意留到最后来谈。
两种不同的攻击者
比较存储之前,先把这两种威胁干净地分开,因为它们太常被混为一谈。
XSS,是攻击者让自己的 JavaScript 跑进了你的同源环境——可能是一段没转义的用户输入、一个被污染的第三方脚本,或一个有漏洞的依赖。一旦如此,他的代码就以和你自己代码完全相同的权限运行:你的脚本能读的它都能读,你的应用能调的接口它都能调。XSS 就是在你的页面里执行代码。
CSRF则不同,某种意义上还更受限:攻击者自己的站点,诱使受害者的浏览器向你的 API 发出一个已认证的请求。它之所以能得逞,是因为浏览器会自动把环境凭据——多数情况下是 cookie——附在发往你域名的请求上,哪怕这个请求是从 evil.example 发起的。攻击者既看不到 token,也看不到响应;他只是借浏览器主动奉上的凭据,让某个操作发生。
把这个区别攥在手里:XSS 从你页面内部读取并行动;CSRF 从外部伪造请求,靠的是浏览器替它把凭据一并带上。几种存储方式,几乎完全是沿着这两根轴排开的。
几种存法,各自暴露了什么
localStorage 和 sessionStorage。 这是大多数 SPA 教程的默认选择,它有一个真实的优点,和一个致命的弱点。它能被页面上任意 JavaScript 读到,所以一行 localStorage.getItem('token') 就把 token 递到了 XSS 手里,后者可以把它发到攻击者的服务器上,在 token 过期前随时随地重放。这就是那个致命弱点。而真实的优点恰是它的镜像:因为这个值只由你自己的代码手动带上请求(作为 Authorization: Bearer 头),浏览器从不自动发送它,所以跨站请求带不走它。换句话说,localStorage 是免疫 CSRF、却对 XSS 致命。(sessionStorage 只在存活时长上不同——只在当前标签页、关闭即清——安全特性上并无二致。)
用一个普通的、非 httpOnly cookie 来装 token。 作为放凭据的地方,这是最坏的两头不讨好:JavaScript 能读到它,而且浏览器还自动发送它,于是你把 localStorage 的 XSS 暴露和 cookie 的 CSRF 暴露一次性全揽了过来。(非 httpOnly cookie 用在别的地方完全合理——存个语言偏好、非敏感的界面状态、一个 double-submit 的 CSRF token——只是别拿来装 token 本身。)
一个 httpOnly cookie。 httpOnly 这个标记让 cookie 对 JavaScript 隐形,document.cookie 看不到它,XSS 也就没法把 token 读出来外传。这是一个真实且宝贵的属性。作为代价,浏览器会在发往该域名的请求上自动带上这个 cookie——受它的 Domain、Path、Secure、SameSite 作用域约束——这就把你稳稳推进了 CSRF 的地界,逼你补上 CSRF 防御(下文详谈)。但这里有个很多建议都讲错了的微妙之处,值得单开一段。
httpOnly 挡得住窃取,挡不住滥用
httpOnly 阻止 XSS 读取 token,却不阻止同一段 XSS 使用它。一个在你页面里运行的攻击者,完全可以就从页面本身发起已认证请求——调你的“改邮箱”接口、生成一个 API key、把你应用能转移的东西转移走——而浏览器会像对待合法代码一样,把那个 httpOnly cookie 附在每一个这样的请求上。攻击者偷不走 token 拿去以后、或换台机器重放,但他能当下、在页面开着的这段时间里,以用户的身份行事。
所以 httpOnly 确实有用:它靠阻止外传把影响范围收窄——没有一份可长期使用的被盗凭据,攻击者也没法从自己的基础设施上重放。但它并不能让 XSS 失效。这正是对“httpOnly cookie 能让你免疫 XSS”这一常见误解的关键纠正:它让你更难被偷,而不是在被攻陷时不被冒充。
存在内存里——一个普通的 JavaScript 变量,或一个模块级闭包——补齐了这组选项。因为什么都不写进磁盘,就没有一份持久副本可偷;标签页一刷新或一关闭,token 就没了。它不会被自动带上,所以像 localStorage 一样免疫 CSRF;而在防窃取这一点上,它比 localStorage 更窄一档:没有存下来的副本可供外传、供日后重放。这是个真实但有限的好处——闭包里的变量甚至未必能被注入的代码直接读到,可同源环境中的 XSS 照样能调已认证接口、挂进你的请求链路,或在刷新流程里截走一个新 token。存在内存里,挫败的是离线重放;它拦不住同源 XSS 滥用当前会话。代价在体验上:token 每次刷新都会丢,于是你需要一条悄悄换新的路子——而这正是下一篇要讲的 refresh token 那套机制。
争论掩盖的真相:XSS 面前,存储位置不是防线
把上面几种并排一放,就浮现出一个能重新框住整个问题的规律。如果攻击者能在你的同源环境中运行 JavaScript,没有哪种客户端存储位置能完全护住 token。 在 localStorage 里,它被直接偷走;在 httpOnly cookie 里,它读不到,但攻击者照样借它以用户身份行事;在内存里,也许没有可直接读到的副本,可 XSS 仍能借用会话——去调已认证接口,或在 token 被刷新时把它截下来。没有任何一种浏览器存储的安排,能在攻击者已经在你页面上执行代码时还保得住 token。
这就引出了那份诚实的防御优先级,而它和大家惯常的问法恰好相反:防住 XSS 才是第一道防线;存储选择只决定一次得手的 XSS 会坏到什么程度。 谁要是告诉你“把 token 放进某处,XSS 就伤不到你”,那都是言过其实——存储是止损,不是预防。所以真正保护用户的功夫,大多落在存储决策之外:依赖你框架默认的输出转义(React、Vue、Angular 默认都会转义),把每一处对不可信数据用 dangerouslySetInnerHTML 或 v-html 都当成隐患;上一份严格的 Content-Security-Policy,它既限制被注入的脚本能做什么,也限制它能把偷到的 token 外传到哪里;考虑用 Trusted Types 掐掉基于 DOM 的注入点;对那些能固定版本、能校验完整性的第三方静态资源,用 Subresource Integrity 钉住它们(它校验的是一个已知文件,所以是个定点手段,不是供应链安全的通解);并且勤打依赖的补丁,因为你构建里一个被投毒的包,就是一个拥有你应用全部权限的 XSS。存储选择是个真实的决定,但它是第二位的,不是第一位。
如果你用 cookie,就把 CSRF 认真处理好
选了 httpOnly cookie,就意味着把 CSRF 扛了下来,所以值得搞清楚真正管用的是什么,而不是抓一把民间偏方。
SameSite 这个 cookie 属性,是当下的第一道防线。SameSite=Lax——如今主流浏览器的默认值——不会在跨站的子资源请求里带上 cookie,也不会在不安全的顶层导航里带上它(一个导航到你应用的跨站表单 POST 就带不上),但仍在普通的顶层 GET 导航时带上,好让普通链接和登录照常工作。它是一个有力的缓解,而不是一道无条件的屏障:浏览器出于兼容性开过一些口子(尤其那个短暂的“Lax-allowing-unsafe”窗口,让刚设置不久的 cookie 仍会被带上一个顶层的跨站 POST),所以把它当成抬高门槛,而不是把门焊死。Strict 更紧,但正因为它连“用户从一个跨站链接进来”时都不带 cookie,你的应用在那次首跳里会显得像未登录,直到一个同站请求把它重新带上。None 与其说“重新打开了 CSRF”,不如说是把 SameSite 这层缓解整个移除了——它如今必须配 Secure,而你也就回到了依赖服务端 CSRF 防御的状态。
SameSite 是必要的,却不是完整答案。它对同站攻击者毫无保护——一个被攻陷或被攻击者控制的子域,对你的 cookie 而言就是同站——它有历史遗留的边角情况,老客户端也不认它。所以纵深防御依然要上:一个同步器或 double-submit 的 CSRF token、对改状态的请求校验 Origin/Referer 头,或者要求一个跨站表单设置不了的自定义头(浏览器不会让一个简单的跨源表单加上任意头)。作为对照值得点名:localStorage 加 Authorization: Bearer 这套天生免疫 CSRF,恰恰因为浏览器从不主动带上那个头。这份免疫,是 localStorage 方案唯一真实的安全优势——而代价,如我们所见,是它的 XSS 暴露。
业界实际收敛到了哪里
有两套方案,已成了务实的答案,值得成对地理解。
第一套,是放在内存里的短有效期 access token,配一个 httpOnly、Secure、SameSite 的 refresh cookie。 access token 存在一个 JavaScript 变量里——不持久化,所以一次刷新之后没有任何东西留下来供日后偷取——并作为 Bearer 头发出,因而免疫 CSRF。关键是,它的有效期很短。而 refresh token,那个你真正需要保护的长期凭据,放在一个作用域限定到刷新端点的 httpOnly cookie 里,JavaScript 读不到。页面加载时、以及每当 access token 过期,应用就去调刷新端点换一个新的。这是有意为之:把长期凭据放到 JavaScript 读不到的地方,让够得着的那个凭据保持短命,于是攻击者能悄悄外传、拿去离线重放的,最多只是一个剩不了几分钟的 token。不过要把它能买到什么、买不到什么说清楚:一个在你的同源环境中运行的 XSS,依然可以自己去调刷新端点、从响应里读出新的 access token、把会话一直续下去——而在这期间,它也能直接以用户身份调你的业务接口。短有效期压缩的是单个被盗 token 拿到别处重放的价值;它并不能把一个活着的同源 XSS 压到几分钟。这套方案真正的收益,是把 refresh token 挡在 JavaScript 够不到的地方,而不是让 XSS 变得无害。它整个都仰仗下一篇要讲的那套生命周期机制——短的有效期,加上会轮换的 refresh token。
第二套,在“抵御 bearer token 外传与离线重放”这一点上更强,是 BFF(Backend-for-Frontend,面向前端的后端)模式:浏览器根本不持有 JWT。SPA 只跟自己的后端对话,靠一个普通的 httpOnly 会话 cookie 认证;由那个后端持有各种 token,并代表用户去调下游 API。这正是当前面向浏览器应用的 OAuth 指南对更高安全要求的系统所推荐的,理由恰好就是本文一路铺垫的那个:它把 bearer token 彻底移出 JavaScript 够得到的范围,页面里就没有东西可外传、可拿去别处重放了。它仍然不是 XSS 的解药——一个在你的同源环境中运行的攻击者,依然能借浏览器为合法代码附上的那同一个 httpOnly 会话 cookie,通过 BFF 发起已认证操作。BFF 消掉的是 token 窃取与离线重放,不是同源滥用。代价在架构上——你得有那个后端组件,它也重新引入了一些服务端会话状态(以及那个 cookie 自己的 CSRF 处理)——但它是绕开了存储之争,而不是硬去把这场争论打赢。
来自第一篇的提醒,以及把它变具体的工具
很容易把 localStorage 当成一个藏东西的地方。它不是。一个已签名的 JWT——也就是 JWS,本系列和这个解析器打交道的就是它——它的 payload 是 Base64URL 编码,可读、并未加密,正如第一篇讲过的那样。(加密的 JWE 是例外,而且在浏览器认证里很少见。)所以躺在 localStorage 里的那个 token,就是一串明明白白可读的字符串,解出来是一堆明明白白可读的 claim。把它粘进 JWT 解析器,你看到的,正是一个 XSS 读到 localStorage 那一刻所看到的东西:整个 token,以及里面的每一个 claim,一览无余。(想手动把某一段拆开看,Base64 编解码工具会在更底一层给你同样的结果。)这也正是你永远不该往 payload 里塞任何敏感信息的原因——存储藏不住它,token 本身也藏不住。
三个该退休的迷思
“只要我把输入都过滤干净,localStorage 就没问题。” 过滤能减少 XSS,却很少能根除它,而且只要漏掉一个注入点、或引入一个被投毒的依赖就够了。把整个可重放的 token 押在“我的应用永远零 XSS”上,是一注不值得下的赌——而这正是“把长期凭据放到 JavaScript 够不到的地方”这一整套理由所在。
“httpOnly cookie 能让我免疫 XSS。” 它们阻止 token 被读取、外传;却不阻止一个 XSS 在用户登录期间、从你页面内部发起已认证请求。httpOnly 限制损害,不预防损害。
“直接把 JWT 塞进 cookie 就行了。” 如果你反正都要用一个有状态的 httpOnly cookie,那就值得问一句:你到底需不需要 JWT?一个自包含的 token,是在验证必须无状态、必须跨多个服务分散进行时才显出价值;而对一个只跟自己后端说话的第一方 Web 应用来说,一个由服务端会话状态支撑的、不透明的普通会话 cookie,往往更简单——并且(下一篇会讲得很直观)撤销起来容易得多。选不选 JWT,该是一个关于你验证架构的决定,而不是条件反射。
关于适用范围,还有两点要快速说明。这场争论专属于浏览器:原生移动应用应当用平台的安全存储(iOS 的 Keychain、Android 的 Keystore),而不是任何类似 localStorage 的东西;桌面或 Electron 应用则各有各的注意事项。另外,这里讲的一切都替代不了传输安全——全程都假设你处处走 HTTPS,否则“存哪”这个问题根本无从谈起。
这一篇把你带到了哪里
那个总是胜出的安排——短有效期 access token 配一个守得好的 refresh token,或是一个把 token 彻底挡在浏览器之外的 BFF——降低的是 token 被偷走的难易程度。但“降低”是个诚实的用词:面对一次真实的 XSS,总有一部分暴露留在那儿,而且 token 也会从别的渠道泄露。存储决定的是一个 token 有多容易被偷;它决定不了一个被偷走的 token 一旦流出、还值多少。而后一个问题——把任何单个 token 的有效寿命压短、让凭据轮换以致窃取变得可被察觉、以及能把一个已被攻陷的会话关停——就是 token 的生命周期,也正是最后一篇的主题:短的有效期、refresh token 轮换,以及撤销。