前端圈子里有一个吵了十年也没吵出结论的问题:登录后拿到的那个令牌,到底该存在哪。localStorage存取方便,httpOnly cookie更安全但配置麻烦,两派各有拥趸。真正值得关注的不是这个老问题本身,而是一个反常识的事实:连Auth0、Okta这两家把"专业身份认证"当卖点的头部服务商,自家SPA默认方案都没有站在安全教科书推荐的那一边。
localStorage的问题不在"会不会被攻破",而在"攻破后能拿走多少"
最常见的教程写法是:登录成功拿到一个JWT,塞进localStorage,之后每次请求带上Authorization: Bearer头。JWT的卖点是"无状态"——服务器验证签名就信任载荷,不用查数据库。问题是localStorage对页面上所有JavaScript公开,你的代码、第三方分析脚本、某个npm依赖包,谁都能读。一旦发生XSS攻击,一行fetch就能把token发到攻击者服务器,对方拿着它可以在任何地方冒充你,直到token过期。
常有人说"反正XSS一来就全完了,藏哪都一样"。这话只对了一半:XSS确实能让攻击者在页面里为所欲为,但token能不能被读走,决定了这次入侵是"一次性带走的钥匙"还是"只能在受害者浏览器里、被服务器风控和日志盯着的临时作案"。前者攻击者关掉标签页照样用你的身份操作到天荒地老,后者一旦用户关闭页面,攻击就结束了。
- 结论.能不能读走token,决定攻击是永久生效还是仅限当场,这才是选型的真正分野。
教科书里的标准答案:三段式架构,代价是CSRF
安全社区公认的做法是把access token放进内存变量(刷新页面就丢,配合短生命周期),再用一个httpOnly cookie存放refresh token负责静默续期,JS完全碰不到它。这套组合把"被偷走"的风险降到很低。
但httpOnly cookie会被浏览器自动附带发送,这打开了CSRF(跨站请求伪造)的口子:一个恶意网页可以诱导你的浏览器悄悄向你的银行、你的后台发起带cookie的请求。防御手段并不神秘——CSRF token双重校验、SameSite属性、服务器校验Origin来源,三层叠加基本能堵住。但这里有个容易被忽略的细节:CSRF token本身必须放在JS能读到的普通cookie或页面里,不能也做成httpOnly,因为前端要把它复制进请求头去对账。不是所有敏感信息都能一藏了之。
Auth0、Okta的默认值,其实没跟上这套教科书
这里出现了一个反差。Auth0官方支持文档写得很清楚,SPA SDK默认把token缓存在内存里,刷新页面就掉登录状态;如果开发者想要"刷新不掉线",常见做法是把缓存切成localStorage——这等于主动放弃了内存方案的安全边界。Okta在SPA场景把refresh token rotation设为默认,带重放检测,思路更进一步,但依然是"token交给前端管理"的模式,不是把主令牌锁进httpOnly cookie。真正把cookie-first做成默认架构、并当作官方安全卖点的,只有Clerk。Supabase Auth的默认会话模型也是客户端可读的access/refresh token,想要httpOnly cookie得自己搭服务端组件。
| 服务商 | 默认存储方式 | 判断 |
|---|---|---|
| Auth0 | 内存,跨刷新常改用localStorage | 开发体验优先,安全边界随之松动 |
| Okta | Token交前端管理+刷新轮换 | 强化了续期防重放,未锁死主令牌 |
| Clerk | httpOnly cookie为核心 | 少数把cookie-first做成默认的厂商 |
| Supabase | localStorage类客户端可读存储 | 要cookie方案需自建服务端组件 |
教科书的最佳实践和厂商给你的默认配置,不是一回事
这不是这几家产品不专业,而是SPA天生要跨域调用API、要在不同前端框架里通用,httpOnly cookie在跨域场景下配置成本更高,厂商为了通用性和接入速度,往往把安全参数的调节权交还给开发者。
- 风险.不少团队接入Auth0、Okta之后从不检查token存储配置,默认值就是终版方案,这恰好是攻击者最爱的那种"教科书都写了但没人做"的缺口。
对已经在用localStorage存长效JWT的存量项目,最现实的第一步不是推翻重写,而是把token生命周期缩短、加上刷新轮换和撤销列表,把"偷回家的钥匙"变成"随时能作废的钥匙"。真要迁移到内存+cookie方案,改造成本集中在CSRF防御的三层落地上,值得先在新项目里跑通,再回头改老代码。
