认证凭证的边界工程:把浏览器攻击面拆成可验证的控制点
前端认证设计不应停留在"Cookie 和 Token 哪个更安全"。Cookie 是浏览器保存和自动发送数据的机制,Session ID、JWT、Access Token、Refresh Token 才可能是凭证;登录态则是服务端与客户端共同维护的认证上下文。
更有价值的问题是:
- 页面中的 JavaScript 是否能够直接读取凭证?
- 浏览器是否会在攻击者诱导的请求中自动携带凭证?
- 凭证泄露或重放后,服务端能否识别、撤销并收敛风险?
这三类问题分别对应 XSS 凭证导出、CSRF 请求冒用,以及令牌重放与撤销。它们不能靠一个 HttpOnly、一个 SameSite 或把 Token 放进 localStorage 一次解决。
本文聚焦凭证的存放、传输、刷新与攻击面切断,不展开登录态失效流程或会话状态机设计。
先拆开认证、授权、会话与凭证
- 认证(Authentication):确认"你是谁",例如密码登录、WebAuthn 或 OIDC 登录。
- 授权(Authorization):决定"你能做什么",例如是否能够退款、导出数据或修改管理员配置。
- 会话(Session):认证成功后,服务端围绕用户、设备、客户端和有效期建立的连续上下文。
- 凭证(Credential):客户端在后续请求中提交的证明材料,例如 Session ID、Access Token 或 Refresh Token。
Cookie 不等于 Session,JWT 也不等于登录态。Session ID 可以放在 Cookie 中,JWT 也可以作为 Cookie 值发送;Access Token 可以放在内存中,通过 Authorization: Bearer 手动附加;BFF 则可以把 OAuth Token 留在服务端,只向浏览器发一个 HttpOnly 会话 Cookie。
因此,第一步不是选 Cookie 还是 Token,而是确定:哪些凭证必须暴露给浏览器 JavaScript,哪些凭证可以留在服务端。

两条主要攻击链:被读走,和被自动带上
XSS:攻击者在同源页面中代替用户操作
如果攻击者能够在同源页面执行脚本,风险不只是读取 localStorage。放在 localStorage 或 sessionStorage 中的 Access Token、Refresh Token 和 Session ID,都可能被直接读取并上传,随后在其他设备或网络环境中重放。
HttpOnly Cookie 能降低凭证被脚本直接导出的风险,因为 JavaScript 无法通过 document.cookie 读取它。但它不能阻止 XSS:恶意脚本仍在受害者的同源页面中运行,可以调用同源接口、读取页面数据、修改邮箱或创建 API Key,浏览器也会在请求中自动携带 Cookie。
应区分两种后果:
- 凭证导出:攻击者拿走凭证后,可脱离受害者浏览器重放。
- 会话内代发:攻击者暂时读不出 HttpOnly Cookie,但能借当前会话执行操作。
前者要求尽量减少 JavaScript 可读的高价值凭证,后者必须依靠 XSS 治理、敏感操作保护和必要的重新认证。
CSRF:攻击者不读取凭证,也能诱导浏览器携带它
满足 Domain、Path、Secure 和 SameSite 等条件时,浏览器会自动把 Cookie 附加到请求。攻击者因此可能诱导已登录用户向受信站点发起状态变更请求。
Authorization: Bearer <token> 通常需要页面 JavaScript 主动设置。第三方网站一般不能替目标站点设置这个自定义授权头,因此传统 CSRF 面会缩小;但代价是 Token 需要暴露给 JavaScript,XSS 导出和重放风险增加。
| 方案特征 | 更突出的风险 | 首要防线 |
|---|---|---|
| Cookie 自动携带 | CSRF、同源 XSS 代发 | SameSite、CSRF 校验、Origin/Referer、Fetch Metadata、XSS 治理 |
| Bearer 手动附加 | XSS 导出、Token 重放 | 减少持久化、短期 Token、刷新保护、XSS 治理 |
先决定凭证暴露面,再决定存放位置
HttpOnly Cookie:同站 Web 的常见默认方案
对于传统同站 Web、SSR 应用或 BFF 架构,通常可以让浏览器只持有一个 HttpOnly、Secure 的会话 Cookie:
http
Set-Cookie: __Host-session=<opaque-session-id>; Path=/; Secure; HttpOnly; SameSite=Lax
各属性的责任不同:
Secure:仅通过 HTTPS 发送。HttpOnly:阻止 JavaScript 直接读取。SameSite=Lax:限制多数跨站请求中的自动携带,同时保留常见的跨站顶级 GET 导航体验。它不是完整的 CSRF 防线。Path=/:覆盖当前主机的整个路径空间。__Host-:要求使用Secure、Path=/且不能设置Domain,使 Cookie 保持 host-only。
若只服务于 app.example.com,通常应省略 Domain。设置 Domain=.example.com 会把 Cookie 扩展到多个子域;任一子域被接管、运行旧应用或承载不完全可信内容,都可能扩大攻击面。
SameSite=None 表示跨站请求也可以携带 Cookie,并且必须同时设置 Secure。只有确实需要跨站嵌入或跨站身份流程时才应使用,并为相关接口补充更严格的来源校验和 CSRF 防护。
Web Storage:可用,但不适合作为高价值凭证保险箱
localStorage 持久化且同一 Origin 的脚本可读取;sessionStorage 只是生命周期更短、默认不跨标签页共享,同样无法抵御同源 XSS 读取。因此不应把长期 Access Token、Refresh Token、JWT 或 Session ID 作为常规方案放入这两类存储。
若历史架构必须让浏览器直连 API 与 Bearer Token,至少应做到:
- Access Token 尽量短期有效;
- 不把 Token 写入日志、埋点、错误上报、URL 查询参数或浏览器历史;
- 不把 Refresh Token 当作永不过期的 Access Token;
- 在服务端实现刷新轮换、重用检测、撤销和异常使用审计。
内存 Token:减少持久暴露,但增加协同成本
把短期 Access Token 放在运行时内存中,可以减少页面刷新后凭证继续存在的时间,但刷新页面后需要重新建立认证上下文,多标签页之间也不会天然共享状态。若再通过共享存储、隐藏 iframe 或其他后台通道解决协同问题,可能重新引入凭证暴露面。
因此,内存 Token 是一种减暴露策略,不是自动安全的存储替换。团队必须同时设计刷新后的重建、多标签协调、并发刷新和失败收敛。
BFF:让高价值 OAuth Token 留在服务端
BFF 通常采用以下边界:
- 浏览器只与 BFF 建立 Cookie 会话;
- BFF 在服务端保存或管理 Access Token、Refresh Token;
- BFF 代表浏览器调用下游资源服务;
- 浏览器 JavaScript 不直接持有高价值 OAuth Token。
BFF 不能消除 XSS,恶意脚本仍可能调用同源 BFF 接口,但它减少了 Token 被导出后在站外重放的机会,也便于集中处理刷新、撤销、受众约束和审计。
Cookie 属性是一组作用域约束
| 属性 | 它约束什么 | 常见误解 |
|---|---|---|
HttpOnly |
JavaScript 不能直接读取 Cookie | 不能阻止同源 XSS 发起已认证请求 |
Secure |
仅随 HTTPS 发送 | 不等于凭证不会因其他漏洞泄露 |
SameSite |
跨站请求中的自动携带规则 | same-site 不等于 same-origin |
Domain |
Cookie 可发送到哪些主机 | 设置父域会扩大到子域 |
Path |
Cookie 在哪些路径发送 | 不是可靠的访问控制边界 |
Max-Age / Expires |
浏览器侧保存时间 | 不替代服务端撤销 |
__Host- |
强制 host-only、Secure、Path=/ |
不能用于必须跨子域共享的 Cookie |
SameSite 按站点而不是 Origin 判断。同一可注册域下的兄弟子域可能仍属于 same-site,因此不完全可信的兄弟子域仍可能影响 app.example.com 的威胁模型。应把 SameSite 视为纵深防御,而不是唯一防线。
刷新令牌:不仅是续期机制,也是泄露探针
Access Token 用于资源接口,宜设置较短生命周期;Refresh Token 只用于换取新的 Access Token,应具有更严格的存储、绑定、撤销和重放检测策略。
对于 public client,RFC 9700 要求授权服务器采用发送者约束刷新令牌或刷新令牌轮换等机制来发现重放。浏览器场景中常见的是刷新令牌轮换:

- 客户端提交
R1; - 服务端验证后签发
A2和R2; - 服务端立即使
R1失效,并保留授权链关联; - 再次收到
R1时,识别为并发误用或可能的重放; - 按风险策略撤销该授权链上的活动刷新令牌,并要求重新认证。
刷新接口是高价值凭证交换点,至少应支持 scope 和资源受众约束、旧 Token 失效、重用检测、登录登出及风控事件撤销、绝对有效期和闲置过期,并避免在前端日志、网络错误对象或监控事件中记录 Token。
前端刷新:单飞、有限重试、明确边界
多个请求同时收到 401 时,不能让它们各自使用同一个旧 Refresh Token 刷新。客户端应共享一次进行中的刷新请求:
ts
let refreshPromise: Promise<void> | null = null;
async function refreshOnce() {
if (!refreshPromise) {
refreshPromise = fetch('/auth/refresh', {
method: 'POST',
credentials: 'include'
})
.then((res) => {
if (!res.ok) throw new Error('refresh failed');
})
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
async function authenticatedFetch(input: RequestInfo, init?: RequestInit) {
const response = await fetch(input, {
...init,
credentials: 'include'
});
if (response.status !== 401) return response;
await refreshOnce();
return fetch(input, { ...init, credentials: 'include' });
}
这段代码仅表达 Cookie 会话下的"单飞刷新"思想。生产实现还必须:
- 排除刷新接口自身,避免递归;
- 只对明确表示"凭证过期"的响应刷新,不要把所有 401 都当作可刷新;
- 原请求最多重试一次;
- 处理请求体已被消费、非幂等操作重复提交和必要的幂等键;
- 刷新失败后统一停止重试、清理前端用户态并进入重新认证流程。
服务端仍是最终裁决者,必须独立验证签名或完整性、签发者、受众、有效期、scope、权限和撤销状态。前端只能负责安全携带、减少暴露和体验收敛,不能承担授权决策。
CSRF:拒绝不可信的状态变更请求
Cookie 会话下,建议组合使用以下防线:
- 状态变更不用 GET。 删除、支付、绑定、改密、改邮箱等操作使用
POST、PUT、PATCH或DELETE。 - 校验请求来源。 优先校验
Origin;缺失时根据兼容性策略检查Referer。反向代理后的协议、主机和端口必须经过规范化,不能只做字符串片段匹配。 - 使用 Fetch Metadata。 对状态变更请求,可将
Sec-Fetch-Site: cross-site视为默认不可信;若不完全信任兄弟子域,对same-site也应谨慎处理。旧浏览器、WebView 和特殊跳转可能缺少这些请求头,因此必须使用 Origin/Referer 或 CSRF Token 回退。 - 高风险接口增加 CSRF Token 或交互确认。 有状态服务可使用同步器 Token;无状态场景可考虑双重提交 Cookie。转账、创建密钥、修改 MFA 和导出敏感数据等操作,还应视风险增加用户确认或重新认证。
XSS:HttpOnly 是减损,不是修复
XSS 执行后,攻击者可以读取页面内容、监听交互、调用同源接口并诱导用户完成敏感确认。因此应:
- 使用框架默认转义,谨慎使用危险 HTML API;
- 按 HTML、属性、URL、CSS 和 JavaScript 输出上下文分别编码;
- 审查
innerHTML、outerHTML、document.write、动态脚本、eval和字符串形式定时器; - 渲染富文本时使用持续维护的净化器,并明确允许的标签、属性、协议和嵌入行为;
- 逐步部署基于 nonce 或 hash 的严格 CSP;
- 在兼容性和依赖允许时启用 Trusted Types,限制危险 DOM sink;
- 严格控制埋点、客服、A/B 实验、广告和标签管理脚本等第三方代码。
例如:
http
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-<per-response-random-value>' 'strict-dynamic';
object-src 'none';
base-uri 'none';
require-trusted-types-for 'script';
trusted-types app-sanitizer;
这只是策略示例。实际启用前,应结合构建产物、第三方脚本、浏览器兼容性和报告模式验证;CSP 不能替代输出编码、富文本净化和安全 DOM API。Trusted Types 也可能使未适配的库直接报错,应先盘点并逐步收敛。
按业务形态选择架构
同站 Web、SSR 或全栈框架
优先考虑服务端 Session ID + HttpOnly Cookie,并配合 host-only、明确 SameSite、CSRF 校验、服务端撤销和 XSS 治理。
SPA + 独立 API
先确认浏览器是否真的必须持有 Token。若必须直连资源服务,可使用短期 Access Token 和受保护的刷新机制,但不要默认把长期 Refresh Token 放进 Web Storage;应实现刷新轮换、重用检测和撤销。
OAuth/OIDC、多资源服务或复杂第三方集成
优先评估 BFF,让 BFF 管理 OAuth Token,浏览器只使用 HttpOnly 会话 Cookie。浏览器应用 OAuth 草案可作为设计参考,但 Internet-Draft 不是最终发布规范,具体实现仍应结合已发布规范和身份提供商能力。
多子域、跨站 iframe 或第三方跳转
先明确站点边界、嵌入关系、回调地址、CORS 和第三方 Cookie 依赖,再逐条配置 Cookie。不要为满足单条跨站链路而放宽整个认证域;例外接口应单独配置来源校验、CORS、CSRF 防护和审计。
评审清单
- JavaScript 是否真的必须读取 Access Token 或 Refresh Token?
- 凭证是自动携带还是脚本手动附加?对应 CSRF 和 XSS 风险是否分别处理?
- Cookie 是否具备
Secure、HttpOnly、明确的SameSite,并尽量保持 host-only? - 是否不必要地设置了父域
Domain? - 状态变更接口是否校验 Origin/Referer,并结合 Fetch Metadata、CSRF Token 或双重提交机制?
GET是否完全不产生状态变更?- Refresh Token 是否具备轮换、重用检测、撤销、绝对过期和闲置过期?
- 客户端是否只有一次并发刷新,且最多重试原请求一次?
- 是否考虑请求体重放和非幂等操作的幂等性?
- XSS 防护是否覆盖框架逃生口、富文本净化、危险 DOM sink、CSP、Trusted Types 和第三方脚本?
- 服务端是否独立完成凭证校验、权限判断、受众校验、撤销和高风险操作的重新认证?
最终原则不是"永远选 Cookie"或"永远选 JWT",而是:先最小化凭证进入 JavaScript 的机会,再为自动携带建立 CSRF 防线;让服务端掌握验证与撤销能力,再优化前端的无感刷新体验。
参考资料
- MDN:Set-Cookie header
- MDN:Secure cookie configuration
- OWASP:Cross-Site Request Forgery Prevention Cheat Sheet
- OWASP:Cross Site Scripting Prevention Cheat Sheet
- RFC 9700:Best Current Practice for OAuth 2.0 Security
- IETF Internet-Draft:OAuth 2.0 for Browser-Based Applications
- MDN:Content Security Policy