前端鉴权实战:Token 存哪、怎么刷新、怎么防 XSS 与 CSRF
“localStorage 里存 token 有 XSS 风险,那放 cookie 就安全了”——错,CSRF 在等着。
前后端分离项目的鉴权方案,网上一搜全是互相矛盾的答案。这篇不讲理论推演,直接给结论和落地代码:Token 放哪、过期怎么无感刷新、XSS 和 CSRF 各自怎么防。这套方案我用在一个有支付功能的项目里,安全评审一次过。
一、先分清两个威胁,方案才不纠结
| 威胁 | 攻击者怎么拿/用你的凭证 | 防御核心 |
|---|---|---|
| XSS | 注入脚本在你的页面上下文里执行,能读 JS 能读的一切 | CSP、不把 token 放 JS 可读的地方、输入输出转义 |
| CSRF | 恶意网站诱导浏览器自动携带 cookie 发请求(不需要读任何数据) | Cookie SameSite、CSRF Token |
记住这句就够:XSS 防的是"读走",CSRF 防的是"带上"。Token 放 localStorage 能防 CSRF(恶意站读不到),但挡不住 XSS;放 cookie 防得住"读走"(httpOnly),但要防 CSRF。没有银弹,是组合拳。
二、我的推荐架构:双 Token + httpOnly Cookie
Access Token(短期,15 分钟) → 内存中持有,Authorization 头发送
Refresh Token(长期,7~30 天) → httpOnly + Secure + SameSite=Strict cookie
为什么这么分:
- Access Token 放内存:刷新页面就没了,XSS 读到了也只有 15 分钟寿命,且不参与 CSRF(自定义请求头天然免疫,跨站表单带不上它)。
- Refresh Token 放 httpOnly cookie:JS 读不到,XSS 偷不走;只发往
/auth/refresh一个接口,攻击面被锁死;SameSite=Strict让跨站请求根本不携带它,CSRF 无从谈起。
后端设置 refresh cookie 的关键属性(Express 示例):
res.cookie("refresh_token", token, {
httpOnly: true, // JS 不可读
secure: true, // 仅 HTTPS
sameSite: "strict", // 跨站请求不携带
path: "/auth/refresh", // 只在这个路径发送,缩小暴露面
maxAge: 7 * 24 * 3600 * 1000,
});
path 这一手经常被忽略:refresh token 只需要给刷新接口用,限定 path 后,其他任何请求(包括被 XSS 劫持的请求)都带不上它。
三、无感刷新:用户永远不该看到"请重新登录"
Access Token 15 分钟过期,如果用户被打断到登录页,产品体验就崩了。标准解法:请求拦截器发现 401 → 用 refresh token 换新 token → 重放原请求。Axios 实现:
let refreshing: Promise<string> | null = null;
api.interceptors.response.use(undefined, async (error) => {
if (error.response?.status !== 401 || isRetry(error.config)) throw error;
refreshing ??= refreshToken().finally(() => (refreshing = null)); // 并发只刷一次
const newToken = await refreshing; // 所有请求等同一个结果
error.config.headers.Authorization = `Bearer ${newToken}`;
error.config._retried = true;
return api(error.config); // 重放原请求
});
两个必须处理的细节:
- 并发去重:页面一进来发 8 个请求,token 恰好过期,8 个 401 同时到。不去做并发合并,就会发起 8 次刷新、7 次因 refresh token 被轮换而失败、用户被登出。
refreshing ??= ...保证整页只有一个刷新请求,大家等同一个 Promise。 - 刷新也失败:refresh token 过期/被吊销,此时清状态、跳登录,并把用户当前页面的 URL 存下来(
sessionStorage.redirect = location.href),登录后跳回去。这个"登回原页面"的小细节,留存率差异很明显。
后端配套:refresh token 每次使用后轮换(旧的作废、发新的),并在数据库记录"设备会话"。同一个 refresh token 被第二次使用 = 可能被偷了,直接吊销整个会话。这是 OWASP 推荐的 refresh token rotation。
四、XSS 防御:鉴权方案只是兜底
Token 放内存只是降低损失,XSS 本身还要正面防:
- 不拼接 HTML。React/Vue 默认转义插值,
dangerouslySetInnerHTML/v-html只允许出现在内容经过 DOMPurify 消毒的地方。 - CSP 收紧:
Content-Security-Policy: default-src 'self'; script-src 'self'。禁掉 inline script 和可疑外域,XSS 载荷大多数直接没法加载。注意用了 inline 事件或 JSON.pARSE 水合的要配 nonce。 - 依赖也是攻击面:npm 投毒事件真实发生过,lock 文件提交、CI 用
npm ci、定期npm audit。 - 别在 URL、日志里带 token:token 出现在 query string 会进各种访问日志。
五、CSRF:用对了 SameSite,传统 CSRF Token 可以省
CSRF 的成立条件是"浏览器自动携带凭证发跨站请求"。逐条拆解我们的方案:
- 核心写接口用
Authorization头 → 跨站表单/图片请求带不上,天然免疫。 - refresh cookie 是
SameSite=Strict→ 跨站请求根本不携带,免疫。 - 如果站点必须兼容老浏览器、或 refresh cookie 用了
SameSite=Lax,再补一层 CSRF Token(后端下发随机值,写请求带头携带,服务端校验)。Lax 下跨站 POST 不带 cookie,但顶级导航的 GET 会带,所以GET 永远不要有副作用——这条比任何 token 都重要。
六、登出与"记住我":两个容易做错的点
登出不是删前端 token 就完了。后端必须吊销:access token 短命可以等它自然过期,refresh token 必须立即从会话表删除(或加黑名单)。否则"登出"只是前端表演,偷到 token 的人照用 7 天。
"记住我"的正确实现是延长 refresh token 有效期(7 天 → 30 天),而不是延长 access token。见过把 access token 直接设 30 天的项目,等于把最容易被偷的凭证做成了长期凭证,前面所有努力白费。
七、总结
把整套方案压成一张决策表:
| 问题 | 答案 |
|---|---|
| Access Token 放哪 | 内存,15 分钟,Authorization 头 |
| Refresh Token 放哪 | httpOnly + Secure + SameSite=Strict + 限定 path 的 cookie |
| 过期怎么办 | 拦截器 401 → 并发合并的无感刷新 → 重放请求 |
| refresh 怎么防偷 | 每次轮换 + 二次使用即吊销会话 |
| XSS | 输出转义 + CSP + 依赖审计(token 方案只是降损) |
| CSRF | 自定义头免疫 + SameSite=Strict,GET 永远无副作用 |
按这张表把现有项目对一遍,重点检查三处:refresh token 是不是也在 localStorage 里(最常见的错)、401 并发刷新有没有合并、登出有没有吊销后端会话。

292

被折叠的 条评论
为什么被折叠?



