登录后的那一个小时:把前端会话续期做成可收敛的控制面
很多登录态问题,最初都表现得像一个很小的前端故障:页面突然跳回登录页、某个请求重复提交、一个标签页已经退出,另一个标签页却仍显示"已登录"。
但这些现象通常不是 Cookie 或 Token 本身的静态选择造成的,而是会话生命周期缺少统一控制面:多个请求同时刷新,多个标签页各自维护状态,前端把所有 401 都当成"再试一次",甚至在刷新凭证已经失效后继续重放请求。
本文不再从"Cookie 和 localStorage 谁更安全"开始,而是围绕一个更具体的问题展开:
登录后的这一个小时里,前端如何让凭证续期、请求重试、页面状态和失效退出最终收敛到同一个结果?
一、先拆开三种状态:刷新凭证,不等于刷新授权
登录态至少包含三层,工程实现中不能把它们压缩成一个 isLoggedIn。
1. 服务端的认证与授权状态
这是最终结论所在的一层,包括:
- 当前会话是否仍然有效;
- 用户是否被登出、冻结或要求重新认证;
- 当前用户是否拥有访问某个资源的权限;
- refresh token 是否仍在有效期内,是否已经被撤销或被判定为重用。
这一层只能由服务端根据会话、凭证、权限策略和业务风险做出判断。前端刷新成功,只能说明服务端签发了新的可用凭证,不能证明用户对所有资源仍然有权限。
HTTP 语义也要求客户端区分认证失败与授权不足:401 表示请求缺少有效认证凭据,403 表示服务端理解请求但拒绝执行。收到 403 时,客户端通常不应该仅仅刷新凭证后再次发送相同请求。RFC 9110
2. 浏览器当前持有的凭证
常见组合包括:
- 短寿命 access token;
- 可轮换的 refresh token;
- 由服务端维护状态的会话 Cookie;
- 用于请求绑定或 CSRF 防护的辅助值。
凭证是访问服务端状态的手段,不是服务端状态本身。凭证过期、被撤销、被轮换或被重放检测命中,都可能让前端看到"之前还登录,现在不能用了"。
3. 前端内存中的展示状态
例如:
ts
type AuthViewState =
| { status: "unknown" }
| { status: "authenticated"; user: User }
| { status: "refreshing"; user?: User }
| { status: "signed-out"; reason: SignOutReason };
authenticated 只是当前界面的展示结论。它可以在服务端会话已经失效后暂时滞后,也可能因为权限变更而不再代表某项具体操作一定可执行。
因此,刷新机制解决的是"如何续领或替换凭证",不是"如何让前端自行证明用户永远有权限"。
二、用一条时间线理解会话续期
一个可控的会话生命周期大致如下:
- 用户完成登录,服务端签发初始凭证;
- 前端以内存 Token、Cookie 或 BFF 会话访问业务接口;
- access token 临近过期时,前端可按策略预刷新;
- 或者业务请求收到契约明确的认证过期响应;
- 当前标签页发起一次刷新,其余请求等待结果;
- 刷新成功后,原凭证被新凭证替换;
- 只有满足重放条件的请求才继续执行;
- 刷新失败后,所有等待请求统一短路;
- 清理前端展示状态,并通知其他标签页回到未认证状态;
- 用户重新认证,或在网络恢复后重新尝试初始化会话。
关键点在于:每个阶段都必须有明确的状态转换,不能由某个请求拦截器临时决定整个应用的登录状态。
三、凭证分工应围绕生命周期,而不是存储阵营
1. access token:短寿命、低权限、可快速失效
access token 用于访问资源服务器,应尽量限制有效时间、受众和权限范围。OAuth 安全最佳实践建议限制 access token 的权限和目标资源,以降低泄露后的影响范围。RFC 9700
在纯浏览器客户端中,将 access token 放在 JavaScript 内存里,可以避免页面脚本直接从 localStorage 或 sessionStorage 读取它,也避免长期持久化。但代价是页面重载后需要重新获得凭证,且多标签页之间不能天然共享。
2. refresh token:高价值凭证,必须依赖服务端轮换或绑定
refresh token 的寿命通常长于 access token,因此它的风险更高。对于公共客户端,RFC 9700 要求授权服务器采用发送者约束或 refresh token rotation 等机制之一来检测或防御 refresh token 重放。轮换意味着每次刷新都签发新的 refresh token,并使旧 token 失效;服务端还需要维护令牌家族,以识别旧令牌被再次使用的情况。RFC 9700
前端不应该自行判断"这次旧 token 重用是不是攻击"。它的职责是:
- 同一会话内避免并发刷新;
- 刷新成功后原子替换凭证;
- 不把新旧凭证同时暴露给不同请求;
- 收到刷新失败或会话失效结论后停止重试并退出。
令牌家族、旧 token 撤销和重用检测必须由授权服务端维护。
3. 服务端会话 Cookie:让浏览器保存句柄,而不是保存全部授权材料
在 BFF 或同源服务端会话架构中,浏览器可以只持有一个受保护的会话 Cookie,由服务端代为管理 OAuth access token 和 refresh token。OAuth 浏览器应用设计文档将 BFF、令牌中介后端和纯浏览器客户端视为不同架构,它们的令牌暴露面与刷新职责并不相同。OAuth 2.0 for Browser-Based Applications
这并不意味着 Cookie 自动安全,而是把部分高价值凭证从浏览器脚本可直接访问的上下文中移出,同时增加了服务端会话管理和 CSRF 防护的责任。
四、Cookie 是传输规则,不是安全结论
如果采用 Cookie 保存会话句柄,至少需要把以下属性放进刷新端点和会话边界的设计里:
Secure:仅通过 HTTPS 发送;HttpOnly:限制 JavaScript 读取 Cookie 值;SameSite:控制跨站请求中是否携带 Cookie;Domain:决定 Cookie 可覆盖哪些主机;Path:限制浏览器在哪些请求路径发送 Cookie;Max-Age/Expires:决定 Cookie 的持久化时间;__Host-前缀:在适用时要求Secure、Path=/且不能设置Domain,有助于将 Cookie 绑定到单一主机。
MDN 建议认证 Cookie 使用 Secure 和 HttpOnly,并尽可能收窄 Domain 与 Path 的范围。MDN:Set-Cookie
但 SameSite 不是完整的 CSRF 防护。它按"站点"判断,而不是严格按源判断;同一可注册域下不完全可信的兄弟子域,仍可能参与风险链路。刷新端点和其他写操作还应结合以下措施:
- 校验
Origin,必要时校验Referer; - 使用 CSRF token 或双重提交 Cookie;
- 严格配置 CORS,不允许任意来源携带凭证;
- 避免让 GET 请求产生状态变化;
- 对第三方嵌入、跨子域和身份提供商回调单独建模。
SameSite 更适合作为纵深防御,而不是唯一防线。OWASP:CSRF Prevention Cheat Sheet
退出时也要注意 Cookie 的删除范围。删除 Cookie 时,通常需要使用与原 Cookie 一致的 Domain 和 Path;否则可能只是新增了一个过期 Cookie,却没有清掉真正生效的那一个。
五、刷新风暴:把请求拦截器改造成单飞控制器
最常见的故障场景是:页面启动时同时发出十几个接口请求,access token 恰好过期。它们全部收到 401,如果每个请求都直接调用刷新接口,就会产生刷新风暴。
当 refresh token 支持轮换时,多个刷新请求还可能互相作废:请求 A 使用旧 token 成功并获得新 token,请求 B 随后使用同一个旧 token,被服务端视为重用,最终可能导致整条令牌家族失效。
前端请求层应采用 single-flight,也就是同一会话同时只允许一个刷新动作:
ts
let refreshPromise: Promise<string> | null = null;
function refreshOnce(): Promise<string> {
if (!refreshPromise) {
refreshPromise = requestRefresh()
.then((result) => {
replaceCredentialsAtomically(result);
return result.accessToken;
})
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
实际实现还需要配合请求队列和状态机:
- 只有认证层白名单错误才能触发刷新;
- 刷新接口本身必须排除在刷新拦截器之外;
- 每个原始请求最多自动重放一次;
- 刷新失败后,等待队列全部失败,不再继续排队;
- 退出流程开始后,禁止新的请求重放;
- 非幂等写请求不能无条件重放;
- 上传流、流式请求和已产生副作用的操作需要单独处理;
- 请求超时、取消和网络断开不能直接等同于凭证失效。
可以将请求状态简化为:
text
正常请求
├─ 成功 ───────────────> 完成
├─ 网络错误/服务端异常 ─> 保留现场,按策略重试或提示
├─ 403 ────────────────> 视为授权不足,不自动刷新
└─ 明确的认证过期
├─ 已有刷新任务 ──> 加入等待队列
└─ 没有刷新任务 ──> 发起唯一刷新
├─ 成功 ──> 分类重放
└─ 失败 ──> 队列短路并统一退出
这里的"明确"非常重要。服务端应通过状态码、WWW-Authenticate 或业务错误码区分 access token 过期、refresh token 失效、账户冻结和权限不足;客户端不能仅凭"看到 401"就推断所有情况都适合刷新。
六、多标签页同步:让体验收敛,但不要把它当安全边界
多标签页至少需要同步以下事件:
logout:用户主动退出;session-invalidated:刷新失败、会话撤销或服务端明确判定失效;reauth-required:敏感操作需要近期认证;user-updated:展示所需的用户信息发生变化。
同源标签页之间可以使用 BroadcastChannel 发布消息。该 API 支持同源窗口、标签页、iframe 和 worker 通信,但通信仍可能受浏览器存储分区等条件影响。MDN:Broadcast Channel API
不支持或不适合使用 BroadcastChannel 时,可以使用 localStorage 触发 storage 事件。需要注意,发起修改的那个窗口不会收到自己的 storage 事件,其他符合条件的同源窗口才会收到通知。MDN:storage event
消息应当设计为幂等事件,而不是携带高价值凭证:
ts
type AuthEvent = {
type: "logout" | "session-invalidated" | "reauth-required";
eventId: string;
issuedAt: number;
reason?: string;
};
收到 logout 或 session-invalidated 后,其他标签页应:
- 清理内存中的用户状态;
- 将等待中的刷新或重放任务标记为失效,并在请求层支持时通过
AbortController取消请求; - 取消尚未发送或尚未重放的请求;
- 清理页面级缓存;
- 停止后台轮询;
- 跳转到登录页或展示重新认证提示。
但这只是体验和故障收敛机制,不是安全边界。前端事件可能丢失,也可能被同源恶意脚本伪造或绕过;服务端仍必须独立校验会话、凭证和权限。
七、XSS 防护的目标是降权,不是制造"绝对安全"的错觉
HttpOnly 能降低脚本直接读取 Cookie 值的风险,但它不能阻止已经运行在同源页面中的恶意脚本借助当前浏览器上下文发起同源操作。
所以,XSS 防护必须进入会话续期链路,而不是只在 Cookie 配置表里出现一行:
- 减少
innerHTML、insertAdjacentHTML等危险 DOM sink; - 对必须写入 HTML 的入口集中净化并审计;
- 使用安全 DOM API 和框架默认转义能力;
- 配置 CSP,降低任意脚本执行概率;
- 对敏感 DOM sink 启用 Trusted Types;
- 严格治理第三方脚本、依赖包和供应链;
- 对改密、支付、绑定身份、导出高敏数据等操作要求近期认证或 MFA;
- 对 access token 限制受众、权限和寿命,缩小脚本滥用后的影响范围。
require-trusted-types-for 'script' 可以限制部分敏感 DOM sink 直接接收普通字符串,要求调用方使用 Trusted Types 生成的值,从而把可审计的 HTML 写入路径收窄。MDN:require-trusted-types-for
需要特别强调:即使 access token 只放在内存里,XSS 仍可能在 token 有效期间调用页面已有的 API。因此,存储位置解决的是"凭证能否被直接取走",而 CSP、Trusted Types、依赖治理和敏感操作再认证解决的是"脚本能否继续扩大权限"。
八、把错误语义变成客户端状态机
建议至少区分以下几类错误:
| 情况 | 客户端动作 |
|---|---|
| access token 过期 | 仅在契约明确允许时触发一次单飞刷新 |
| refresh token 失效或被撤销 | 停止重试、清理状态、重新认证 |
| 403 权限不足 | 保留登录态,提示无权限,不重复刷新 |
| 账户冻结或风险拦截 | 按服务端业务语义展示阻断原因 |
| 网络超时或离线 | 保留页面现场,允许用户重试,不立即退出 |
| 服务端 5xx | 按幂等性和业务重要性决定是否重试 |
| 敏感操作要求近期认证 | 转入再认证或 MFA 流程 |
如果把网络错误也当成登录失效,用户会在地铁、弱网或服务端短暂抖动时被随机登出;如果把所有 401 都当成可刷新,又可能在服务端已经撤销会话后制造无限重试。
九、让续期链路可观测,但不要记录凭证
会话续期需要有独立的指标,而不是只看全局接口错误率:
- 刷新触发率;
- 单飞合并率,即多少个并发请求共享了一次刷新;
- 刷新成功率;
- 401 后恢复率;
- 重放失败率;
- 刷新循环命中次数;
- 刷新接口耗时分布;
- 多标签页退出收敛时间;
- 因网络错误退出的比例;
- 非幂等请求被阻止重放的次数。
日志可以记录匿名会话关联 ID、错误分类、请求类型和耗时,但不得记录 Authorization 请求头、Cookie 原文、access token、refresh token、JWT 载荷或能够重新拼接凭证的字段。
监控的目标不是知道"哪个 token 出问题",而是判断控制面是否正在失控:是否出现刷新风暴、无限重试、标签页状态不一致,或服务端撤销后仍有大量请求继续重放。
十、上线前测试矩阵
至少覆盖以下场景:
| 场景 | 验证重点 |
|---|---|
| 12 个请求同时收到认证过期 | 只产生一个刷新请求,其余请求正确等待 |
| refresh token rotation | 新旧凭证原子替换,旧凭证不会被并发再次使用 |
| 刷新接口返回失效 | 所有等待请求短路,页面统一进入未认证状态 |
| 刷新接口返回 5xx | 不误判为永久退出,且不会无限重试 |
| 网络断开后恢复 | 保留页面现场,恢复后按策略重新初始化 |
| 标签页 A 主动退出 | 标签页 B 清理状态、缓存和后台轮询 |
| 标签页 A 刷新失败 | 其他标签页收到失效事件并停止请求重放 |
| 后台收回用户权限 | 403 不触发刷新,页面正确提示权限不足 |
| XSS 演练 | 无法直接读取 HttpOnly Cookie,敏感操作仍要求再认证 |
| 非幂等写请求 | 刷新后不会无条件重复提交 |
| 上传或流式请求中断 | 不把不可重放请求塞入普通等待队列 |
| 退出期间收到旧请求响应 | 旧响应不会重新写回已清理的用户状态 |
结语:登录态的核心不是"存在哪里",而是"如何收敛"
Cookie、Token、内存、Web Storage 都只是会话设计中的局部选择。真正决定系统是否可靠的,是以下控制面是否完整:
- 三层状态是否分离;
- 凭证是否按寿命和权限分工;
- 刷新是否 single-flight;
- 请求是否有明确的重放边界;
- 令牌轮换是否由服务端维护安全语义;
- 多标签页是否能及时收敛;
- XSS 防护是否覆盖同源代操作风险;
- 401、403、网络故障和服务端异常是否被正确区分;
- 续期链路是否能够被观测和验证。
当这些边界被明确之后,登录态就不再是一组散落在拦截器、状态管理和页面跳转里的临时逻辑,而是一套可以测试、监控和逐步收敛的前端控制系统。
参考资料
- RFC 9700:Best Current Practice for OAuth 2.0 Security
- RFC 9110:HTTP Semantics
- OAuth 2.0 for Browser-Based Applications
- MDN:Set-Cookie header
- OWASP:Session Management Cheat Sheet
- OWASP:Cross-Site Request Forgery Prevention Cheat Sheet
- MDN:Broadcast Channel API
- MDN:Window storage event
- MDN:require-trusted-types-for directive