当在单页应用(SPA)中处理 OAuth 时,将访问令牌和刷新令牌存储在何处一直是个长久以来的争论。localStorage、sessionStorage 和内存变量都难以抵御 XSS 攻击。前后端分离的 BFF 模式是一种将令牌保存在服务器端而非传递给浏览器的设计。本文将梳理其机制和关键实现要点。
前提条件
本文假设存在以下环境:
- 使用 OAuth 2.0 和 OpenID Connect 开发基于浏览器的应用。
- 存在一个 SPA、该 SPA 调用的 API 以及一个授权服务器。
- SPA 及其支持性的服务器端组件可以部署在同一个父域名下。
- 需要使用 HTTPS(签发安全 Cookie 的前提)。
基于浏览器的应用中的 XSS 风险
在浏览器中运行的应用程序代码易受到浏览器执行环境中发生的一切影响。XSS 的影响范围很广,由于攻击代码与应用程序在同一上下文中运行,因此可能执行以下操作:
- 读取存储在 localStorage 或 sessionStorage 中的值。
- 读取可通过 JavaScript 访问的内存变量。
- 执行应用程序能执行的所有 API 调用。
- 通过覆盖内置函数(原型污染)来改变行为。
XSS 的入口点有很多,例如依赖库中的漏洞、自身代码中输入/输出处理的缺陷或第三方脚本被攻破。由于完全防止 XSS 很困难,一个切实可行的策略是“限制入侵发生后的影响范围”。
只要令牌存在于浏览器中,就有可能通过 XSS 被盗取。如果攻击者在其自己的环境中使用了窃取的刷新令牌,即使在用户关闭网站后,他们仍能长时间调用 API。虽然令牌轮换和空闲超时等措施可以减轻影响,但它们并非根本性解决方案。
不将令牌置于浏览器的设计
BFF 模式为 SPA 提供了一个专用的服务器端组件,并将 OAuth 客户端的职责集中于此。SPA 不直接处理 OAuth 流程,而是通过 BFF 进行身份验证和 API 调用。
职责划分如下:
- 与授权服务器的 OAuth 协议通信:由 BFF 处理。
- 持有访问令牌和刷新令牌:仅限 BFF。
- 维护 SPA 与 BFF 之间的认证状态:通过 HttpOnly Cookie。
- API 调用:SPA 将请求发送到 BFF,BFF 将 Cookie 转换为令牌并转发给 API。
在此配置中,令牌仅在 BFF 与其调用的 API 之间流通,对浏览器的 JavaScript 不可见。即使发生 XSS,攻击者也无法提取令牌并在其他地方使用。攻击者所能做的仅限于在用户当前打开的会话范围内向 BFF 发送请求。虽然这种影响不容忽视,但与令牌被盗相比,其持续时间和影响范围已大大受限。
BFF 充当了 OAuth 术语中的“机密客户端”。它持有客户端密钥,并完全在服务器端完成授权码与令牌的交换以及刷新处理。
身份验证流程
典型的身份验证流程如下:

- 当 SPA 启动登录时,它会向 BFF 发送一个登录请求。
- BFF 启动带有 PKCE 的授权码流程,生成一个指向授权服务器的重定向 URL,并将其返回给 SPA。
- SPA 将浏览器导航至该 URL,用户在授权服务器处进行身份验证。
- 授权服务器将授权码返回至 BFF 的重定向 URI。
- BFF 用授权码交换令牌,获取访问令牌和刷新令牌。
- BFF 将令牌存储在自己的安全区域(加密 Cookie、服务器端会话存储等),并仅通过一个 HttpOnly Cookie 将会话标识符返回给 SPA。
- 当 SPA 调用 API 时,它通过 BFF 发送请求。Cookie 同时被发送,BFF 将其转换为访问令牌并转发给上游 API。
- 如果访问令牌过期,BFF 会使用刷新令牌静默更新它。
从 SPA 的角度来看,登录状态由 Cookie 维护,API 请求通过普通的 fetch 调用即可完成。在 SPA 中不存在直接处理 OAuth 令牌或授权码的代码。
必要的安全设置
BFF 签发的 Cookie 必须设置以下属性:
- HttpOnly:禁止从 JavaScript 访问。即使存在 XSS,也无法读取 Cookie 内容。
- Secure:仅通过 HTTPS 发送。
- SameSite=Strict:确保 Cookie 不会随来自其他站点的请求一起发送。这有助于阻止主要的 CSRF 攻击路径,但仅凭此还不够完成 CSRF 防护。
__Host-前缀:在 Cookie 名称前添加__Host-前缀,可确保浏览器将该 Cookie 限制在签发主机内,不与子域名共享。
如果将令牌存储在加密 Cookie(客户端会话)中,则 Cookie 内容需要加密。如果将令牌放置在服务器端会话存储(服务器端会话)中,则 Cookie 仅包含一个会话标识符,因此无需加密。
CSRF 防护不应仅依赖 SameSite
由于采用基于 Cookie 的身份验证,BFF 必须实施针对 CSRF 的防护。SameSite=Strict 是有效的一步,但并非全部解决方案。当 SPA 部署在 www.example.com,而 BFF 部署在 api.example.com 时尤其需要小心。因为 SameSite 的判定是基于站点而非源(origin),来自 example.com 下其他子域名的请求被视为“同站”(same-site),即使设置了 SameSite=Strict,Cookie 仍会被发送。
因此,应通过以下方式之一加强 CSRF 防御:
- 如果 BFF 和 SPA 位于不同的源,则使用 CORS 和 Origin 头部验证进行防御。
- 对于更改状态的请求,使用防伪令牌 / 双重提交 Cookie 方法来验证 CSRF 令牌。
将 CORS 限制为精确的源
CORS 应仅允许 SPA 的确切源。对于涉及 Cookie 的需要凭据的请求,浏览器不允许在 Access-Control-Allow-Origin 中使用通配符(*)。因此,BFF 必须在响应中反映 SPA 的确切源。这种严格限制允许源的做法是 CSRF 防御的一部分。
对于 BFF 持有的会话数据,特别是存储在加密 Cookie 中的令牌信息,需要进行密钥管理。应包含将密钥作为 BFF 设置安全注入并定期轮换的操作。
请注意,将 SPA 和 BFF 放在同一个父域名下,是为了满足同站 Cookie 作为第一方 Cookie 运行的条件。
演进:基于 API 驱动的 BFF
如果像传统 Web 应用那样构建 BFF,那么 BFF 的服务器端渲染就会介入 SPA 的页面跳转。基于 API 驱动的 BFF 可以最大限度地减少这种影响。
角色分为两个:
- OAuth Agent(OAuth 代理):负责 OAuth 协议处理的 API。SPA 通过 JSON 调用它。
- OAuth Proxy(OAuth 代理):作为 API 网关插件运行,从 Cookie 中提取令牌,并将其转发给上游 API。
在此配置下,SPA 只需像调用普通 REST API 一样调用 OAuth Agent。SPA 的前端开发体验可以几乎与引入 BFF 之前保持一致。
采用时需考虑的要点
采用 BFF 模式时,请考虑以下几点:
- 额外的架构组件会增加开发和运维成本。
- SPA 和 BFF 必须部署在同一个父域名下。
- 仅通过反向代理转发
Authorization头部的配置并非 BFF。BFF 的本质是作为机密客户端持有令牌。 - PKCE 和 BFF 是配合使用,而非替代关系。
- BFF 必须在转发前验证目标主机,以防止令牌暴露给非预期的目标主机。
- 退出登录的设计会变得复杂,因为需要关联 SPA 会话、BFF Cookie 和授权服务器会话。
总结
在基于浏览器的应用中安全处理令牌的一个实用方法是不要让令牌存在于浏览器中。BFF 模式将 OAuth 客户端的职责移至服务器端,仅将 HttpOnly Cookie 传递给浏览器。这样即使发生 XSS,也能防止令牌被盗。通过将角色划分为 OAuth Agent 和 OAuth Proxy,可以在保持 SPA 开发体验的同时增强安全性。





