登录态不是一枚令牌,而是一条可撤销的生命线

8 阅读1分钟

登录态不是一枚令牌,而是一条可撤销的生命线

一次典型的登录态故障,往往不是“Token 过期了”这么简单:页面同时发出十个请求,十个请求同时收到 401;其中一个请求先去刷新,另一个标签页却执行了登出;刷新接口返回后,旧状态又覆盖了新状态。最终,用户看到的是“明明退出了却还像登录着”,或“偶发地被踢下线”。

这说明:登录态不是某个 Cookie、JWT 或字段,而是一条持续演化、可被攻击、必须可撤销的凭证生命周期。

设计时不要先问“Cookie 和 Token 哪个更安全”,而应先回答四个问题:

  1. 凭证放在哪里? 浏览器脚本、浏览器网络层,还是服务端?
  2. 谁能读取它? 同源 JavaScript、任意子域、后端会话服务,还是只有 BFF?
  3. 它何时失效? Access Token 到期、空闲超时、绝对超时、密码修改、管理员强制下线,分别如何生效?
  4. 失效后谁负责恢复或终止? 前端能否刷新、后端是否接受旧凭证、其他标签页如何同步?

展示未认证、已建立会话、刷新中、刷新成功、刷新失败、主动登出和强制失效之间的状态转换关系。

先把登录态看成状态机,而不是存储选项

一个可控的登录态至少包含以下状态:

未认证
  └─ 登录成功 → 已建立会话
                    ├─ 正常请求 → 已建立会话
                    ├─ Access Token 临近或已经过期 → 刷新中
                    │                                  ├─ 刷新成功 → 已建立会话
                    │                                  └─ 刷新失败 / 被撤销 → 已失效 → 未认证
                    ├─ 用户主动登出 → 已失效 → 未认证
                    └─ 密码修改、风控命中、管理员下线 → 已失效 → 未认证

浏览器本地认为“已登录”,不等于服务端仍认可该会话。前端缓存的用户资料、内存中的 Access Token,以及仍存在的 Cookie,都不能作为授权成立的最终依据。服务端必须在每次受保护请求中验证会话或令牌状态;前端负责用户体验与请求协调,而不是替代撤销、风控和授权决策。

OWASP 将会话标识视作与主认证同等敏感的凭证:一旦被盗用,就可能在有效期内代表用户执行操作。因此,会话固定、重放、长期不失效,以及登出后旧凭证仍可用,都属于生命周期设计缺陷,而不只是“前端存储不规范”。

威胁模型:不要用一种防线回答所有问题

风险攻击者想得到什么典型前提主要防线
XSS在受害站点执行恶意 JavaScript页面存在脚本注入或危险 DOM Sink输出编码、HTML 清洗、CSP、Trusted Types、第三方脚本治理
Token 泄露直接复制可长期使用的凭证Token 可被脚本、日志、URL 或错误存储读取HttpOnly Cookie、避免 Web Storage、短时效、轮换、撤销
CSRF借助浏览器自动携带的身份发起状态变更服务端依赖 Cookie,且缺少请求来源校验SameSite、CSRF Token、Origin/Referer、Fetch Metadata
重放重复使用已窃取的凭证凭证仍有效且服务端无法识别异常使用短时 Access Token、Refresh Token 轮换、发送者约束、撤销
会话固定诱导用户使用攻击者预先掌握的会话标识登录后未轮换会话 ID登录、提权与重新认证后轮换会话
登出后继续使用使用尚未在服务端失效的旧凭证登出只清了前端状态服务端撤销、会话版本号、刷新令牌家族失效

**HttpOnly 只能降低脚本读取 Cookie 的风险,不能阻止已经执行的 XSS 以当前用户身份调用接口。**Cookie 即使不能通过 document.cookie 读取,仍可能随同源 fetch 或 XHR 自动发送。因此,把 Token 放进 HttpOnly Cookie 不是 XSS 的终点,而是把“复制凭证后离线滥用”缩小为“恶意脚本在线滥用当前会话”。

存储位置决定攻击面,不决定全部安全性

Cookie 是浏览器的传输与存储机制;Token 是凭证格式或授权载体。真正应比较的是:凭证由谁持有、谁能读取、请求如何携带、失效如何执行。

方案浏览器脚本能否读取核心凭证是否自动随请求发送主要优势核心代价与风险
服务端 Session + HttpOnly Cookie否是,受 Cookie 规则约束浏览器不直接持有可读会话秘密;撤销直观必须设计 CSRF 防护、会话存储与跨域策略
Token 放 localStorage / sessionStorage是否,需手动加请求头调用 API 方便一次 XSS 即可能读取并导出凭证;不应保存认证凭证
Access Token 仅存内存当前页面的恶意脚本通常仍可接触否刷新页面后不持久化,泄露窗口较小XSS 仍可实时滥用;刷新与多标签页协调复杂
HttpOnly Cookie + 前端直连 API否是减少脚本读取秘密的机会仍需防 CSRF,跨域 Cookie 与 CORS 复杂
BFF + HttpOnly 会话 Cookie否,OAuth Token 留在服务端浏览器只向 BFF 自动携带会话 Cookie浏览器不直接接触 OAuth Access/Refresh TokenBFF 需要承担转发、可观测性、扩缩容和隐私责任

OWASP 建议不要将认证 Token、Session ID、JWT 或 Refresh Token 存入 localStorage 或 sessionStorage:同源 JavaScript 都可能读取它们,一处 XSS 即可能泄露全部凭证。

对于 OAuth 浏览器应用,BFF 的价值不只是“多一层代理”,而是让 BFF 作为机密客户端处理 OAuth 交互,持有 Access Token 与 Refresh Token,再以 Cookie 会话识别浏览器。这会增加转发、限流、审计、扩缩容和隐私边界等服务端责任。

Cookie 属性:每个属性只解决自己的问题

Set-Cookie: __Host-session=<opaque-id>; Path=/; Secure; HttpOnly; SameSite=Lax
  • Secure:只允许 Cookie 在 HTTPS 请求中发送;不阻止 JavaScript 读取未设置 HttpOnly 的 Cookie。
  • HttpOnly:禁止 JavaScript 通过 Cookie API 读取;不阻止恶意脚本借助浏览器自动携带 Cookie 发请求。
  • SameSite=Strict:限制跨站请求携带 Cookie,适合不依赖跨站回调、嵌入或跳转的会话。
  • SameSite=Lax:在可用性与跨站限制之间折中,但不能替代对状态变更请求的 CSRF 防护。
  • SameSite=None; Secure:允许跨站携带,必须配合 CSRF、CORS 和第三方 Cookie 策略设计。
  • Domain:扩大 Cookie 可发送的主机范围。若非跨子域所需,不要设置。
  • Path:限制发送路径,但不是可靠的安全隔离边界。
  • Max-Age / Expires:定义浏览器保存期限,不等于服务端授权期限。
  • __Host-:要求 Secure、Path=/ 且不能设置 Domain,适合单主机场景。

SameSite 判断的是“站点”关系,不能简单等同于“同源”;同一注册域下的不同子域可能是同站但不同源。若子域并不完全可信,不能因为它们属于同一站点就共享高价值 Cookie。

fetch(..., { credentials: 'include' }) 只是请求浏览器尝试携带凭证;Cookie 是否真的发送,还受 SameSite 和浏览器第三方 Cookie 策略影响。凭证式 CORS 响应必须返回明确的 Access-Control-Allow-Origin,不能使用 *,并配合 Access-Control-Allow-Credentials: true。

XSS 与 CSRF:一个是在站内夺权,一个是借站外伪造

  • CSRF:攻击者通常不能在你的站点执行脚本,但能诱导用户浏览器把 Cookie 带到你的站点。
  • XSS:攻击者已经能在你的站点执行脚本,因此往往可以读取页面数据、调用接口,甚至绕过依赖前端附加请求头的防护。

SameSite 不能替代 XSS 防护,CSRF Token 也不能抵消页面脚本被接管的后果。两者必须独立设计。

XSS 防线应从“禁止读取 Token”升级为“减少可执行路径”

  1. 默认使用框架自动转义能力,把不可信内容当作文本渲染。
  2. 按 HTML、属性、URL、JavaScript、CSS 等上下文分别编码与校验。
  3. 避免把不可信数据交给 innerHTML、outerHTML、document.write、动态事件处理器、javascript: URL、eval 和字符串形式的定时器。
  4. 确需富文本时使用经过审计的清洗策略,并纳入版本管理和测试。
  5. 使用 nonce 或 hash 收紧 CSP;可先通过 Content-Security-Policy-Report-Only 观察违规,再执行阻断。
  6. 在支持的环境启用 Trusted Types,限制字符串直接流入危险 DOM Sink。
  7. 治理第三方脚本和依赖供应链,审查来源、版本、动态加载和标签管理器规则。

CSP 是纵深防御,不能替代输入处理和输出编码;Trusted Types 也不能解决所有 XSS 来源。

Cookie 会话的 CSRF 防护应由服务端判定

对会改变服务端状态的 POST、PUT、PATCH、DELETE 请求,建议组合使用:

  • SameSite=Strict 或 Lax 作为基础限制;
  • 同步 CSRF Token 或经过严格设计的双重提交模式;
  • 校验 Origin,缺失时谨慎回退到 Referer;
  • 使用 Sec-Fetch-Site 等 Fetch Metadata 拒绝明显的跨站状态变更请求;
  • 对改密码、支付、提现、绑定 MFA 等高价值操作要求重新认证、一次性确认或交易级签名。

刷新机制:轮换不是“多发一个 Token”

短时 Access Token 用于缩短单次泄露后的可用窗口;Refresh Token 用于避免用户频繁重新登录。由于 Refresh Token 往往有效期更长,必须被视为高价值凭证。

可靠的刷新策略至少包含:

  • 短时 Access Token;
  • 每次刷新签发新的 Refresh Token,并使旧 Token 失效;
  • 服务端保存 Token 家族关系,检测失效旧 Token 的再次使用,并撤销对应链路;
  • 空闲过期和绝对过期;
  • 改密码、风险登录、权限变化、账号冻结和全端下线时撤销刷新能力;
  • 支持查看和撤销特定设备或全部设备的会话。

RFC 9700 针对公开客户端提出发送者约束或 Refresh Token 轮换等重放检测措施。前端只能在刷新失败后清理状态并引导重新登录,不能单独实现轮换、复用检测、撤销列表或全端下线。

前端刷新协调器:把 401 当作并发控制问题

多个请求同时过期时,不能让每个 401 都各自刷新。在 Refresh Token 轮换模式下,第一个刷新可能已经使旧 Token 失效,第二个请求再使用旧 Token 就可能触发复用检测。

同一页面内可以使用单飞刷新;多个标签页还需要通过 BroadcastChannel、Web Locks 或服务端幂等策略协调。否则,每个标签页各自刷新,仍可能发生竞争。

let refreshPromise: Promise<void> | null = null;
let authGeneration = 0;

class AuthInvalidated extends Error {}

function invalidateAuth() {
  authGeneration += 1;
  // 清理内存状态、取消等待中的请求,并通知其他标签页
}

async function recoverSession() {
  if (!refreshPromise) {
    const generationAtStart = authGeneration;
    refreshPromise = (async () => {
      await refreshSession();
      if (generationAtStart !== authGeneration) {
        throw new AuthInvalidated();
      }
    })().finally(() => {
      refreshPromise = null;
    });
  }
  return refreshPromise;
}

async function authenticatedFetch(input: RequestInfo, init: RequestInit = {}) {
  const headers = new Headers(init.headers);
  const alreadyRetried = headers.get('X-Auth-Retry') === '1';
  const response = await fetch(input, {
    ...init,
    credentials: 'include'
  });

  // 刷新接口本身、登录接口和公开接口应在实际实现中单独排除
  if (response.status !== 401 || alreadyRetried) return response;

  try {
    await recoverSession();
  } catch {
    invalidateAuth();
    throw new AuthInvalidated();
  }

  headers.set('X-Auth-Retry', '1');
  return fetch(input, {
    ...init,
    credentials: 'include',
    headers
  });
}

生产实现还应补足:

  • 每个原请求最多进行一次认证恢复重试;
  • 刷新失败时统一进入“已失效”,拒绝等待队列中的请求;
  • 使用 AbortController 取消已离开页面、已登出或已超时的请求;
  • 使用会话代际号,防止晚到的旧响应覆盖登出后的状态;
  • 不把网络超时、403 权限不足和业务错误一概当成可刷新 401;
  • SSR 中不能复用某个用户的认证上下文给另一个请求,每次服务端请求都应从当前请求构造身份。

多标签页、离线与登出:本地同步不是撤销

用户在标签页 A 登出时,标签页 B 可能仍在轮询接口。应同时执行:

  1. 本地传播:通过 BroadcastChannel 或其他协调机制通知同源页面停止认证请求、清理缓存并跳转登录页。
  2. 服务端撤销:登出接口撤销当前会话或 Refresh Token 家族。即使标签页 B 没有及时收到通知,下一次请求也必须被服务端拒绝。

BroadcastChannel 只负责消息传递,不提供会话撤销语义,不能代替服务端验证。

离线恢复时,不要因为本地还保存着用户资料就直接认定已登录。恢复网络后应由服务端重新确认会话;若刷新失败或会话已撤销,应清理敏感缓存。涉及资金、权限和数据导出的请求,尤其不能仅凭本地状态决定是否继续执行。

对比浏览器可读 Token、HttpOnly Cookie 会话和 BFF 三种架构中凭证所在地、脚本读取能力、CSRF 需求和服务端职责。

按风险选择架构,而不是追求唯一答案

场景建议会话形态必要配套
低风险、同站普通 Web 应用服务端 Session + HttpOnly; Secure; SameSite=Lax/Strict CookieCSRF 防护、会话超时、登录后轮换、XSS 基线治理
含个人数据的 SPA / SSR 应用HttpOnly Cookie 会话,或由 BFF 承担 OAuth 凭证CSRF Token、来源校验、刷新轮换、设备会话管理、CSP
支付、资金、账号安全、管理员后台BFF 或严格服务端会话模型重新认证、交易确认、风险评估、全端撤销、审计、最小权限
跨子域、嵌入 iframe、跨站 API根据真实站点关系单独设计谨慎使用 SameSite=None; Secure、显式 CORS、CSRF 方案和重认证降级路径

如果应用是同站、低风险,后端已有成熟 Session 管理,受限作用域的 HttpOnly Cookie 会话往往更直接。如果应用使用第三方身份提供方、资源服务器分散,或权限敏感且需要降低浏览器暴露 OAuth Token 的机会,BFF 更值得投入。

真正不应接受的方案是:把长期凭证放进浏览器可读持久化存储,再用“我们有 CSP”或“Token 很短”作为唯一补救。前者让 XSS 获得直接导出凭证的能力,后者无法替代轮换、撤销和服务端治理。

发布前检查清单

  • 登录、提权、重新认证后是否轮换会话 ID 或会话代际?
  • 是否避免把认证 Token、JWT、Session ID、Refresh Token 放入 Web Storage、URL、日志和埋点?
  • Session Cookie 是否具备 Secure、HttpOnly 和合适的 SameSite?单主机场景能否使用 __Host-?
  • 是否为所有状态变更接口设计 CSRF 防护,并由服务端校验来源、Token 或 Fetch Metadata?
  • 是否明确输出编码、富文本清洗、危险 DOM API、CSP、Trusted Types 与第三方脚本治理责任?
  • Access Token、Refresh Token、空闲超时、绝对超时和安全事件撤销是否有清晰语义?
  • Refresh Token 是否轮换,服务端是否能检测复用并撤销 Token 家族?
  • 多个 401 是否采用单飞刷新、跨标签页协调、失败广播和重试上限?
  • 登出是否同时完成本地清理、服务端撤销、跨标签页同步与未完成请求处理?
  • SSR、跨域、跨子域、iframe、WebView 与第三方 Cookie 受限场景是否单独验证?
  • 高价值操作是否要求重新认证,而不是只相信一个仍未到期的登录态?

登录态安全的目标,不是让某个 Token “绝对拿不到”,而是让攻击者即使拿到局部能力,也难以长期复制、跨设备重放、静默续期或绕过撤销。把凭证读取权、浏览器自动携带行为、刷新轮换、服务端撤销和 XSS 防线放进同一条生命周期里,前端登录态才真正具备可推演、可审计、可恢复的安全边界。

参考资料