Refresh Token + HttpOnly Cookie + 内存 Token

61 阅读11分钟

现代 Web 应用登录系统架构详解:Refresh Token + HttpOnly Cookie + 内存 Token 与凭证包含机制(增强版)

在现代 Web 应用的安全架构中,用户身份认证(Authentication)和会话管理(Session Management)是核心组成部分。随着单页应用(SPA)、微服务架构以及前后端分离模式的普及,传统的基于 Session 的服务器端状态管理方式逐渐被无状态的 Token 认证机制所取代。然而,Token 机制本身也面临诸多安全挑战,尤其是如何安全地存储访问令牌(Access Token)和刷新令牌(Refresh Token)。为应对这些挑战,业界逐步形成了一套结合 HttpOnly Cookie、内存 Token 存储显式启用“凭证包含”(credentials inclusion) 的混合方案。


一、背景:传统认证机制的局限性

1.1 基于 Session 的认证

早期 Web 应用普遍采用服务器端 Session 机制:用户登录后,服务器生成一个 Session ID 并将其通过 Set-Cookie 返回给浏览器;后续请求中,浏览器自动携带该 Cookie,服务器据此识别用户身份。

Session(会话) 是服务器用来跟踪用户状态的一种机制。当用户首次访问网站时,服务器会为其创建一个唯一的会话标识(Session ID),并将其通过 Cookie 发送给浏览器。后续请求中,浏览器自动携带该 Cookie,服务器据此识别用户身份,并恢复其上下文状态(如登录状态、购物车内容等)。

  • Session 的本质:服务器端保存的一组键值对数据,通常存储在内存、Redis 或数据库中。
  • Session ID 的作用:作为客户端与服务器之间会话的“钥匙”,不包含用户信息本身,仅用于索引服务器上的会话数据。

核心特点:

  • 存储在服务器端(内存、Redis、数据库等);
  • 每个用户独享一个 Session
  • 通过唯一的 Session ID 与客户端关联
  • 具有生命周期(如 30 分钟无操作自动过期)。**

为什么每个用户都要一个 Session?

  1. HTTP 协议是无状态的:每次请求独立,服务器无法天然知道两次请求是否来自同一用户;
  2. 需要维持用户上下文:例如,用户登录后需记住其身份,以便授权访问个人资料、订单等;
  3. 安全性控制:通过 Session 可以实现登出、超时、多设备管理等功能;
  4. 个性化体验:如语言偏好、主题设置等可随会话持久化。

服务器端 Session 机制简单可靠,但存在以下问题:

  • 服务器状态依赖:每个 Session 需要服务器内存或数据库存储,难以水平扩展。
  • 跨域限制:Cookie 默认受同源策略限制,难以支持多域名或微服务架构。
  • CSRF 风险:若未正确防护,攻击者可诱导用户发起伪造请求。

然而,Session 机制依赖服务器状态,在高并发、分布式场景下存在扩展瓶颈,因此催生了无状态 Token 方案。


1.2 基于 JWT 的无状态 Token 认证

为解决上述问题,JSON Web Token(JWT)等无状态 Token 机制被广泛采用。JSON Web Token(JWT) 是一种开放标准(RFC 7519),用于在网络应用环境间安全地传输声明(claims)。JWT 通常由三部分组成,以点号分隔:

Header.Payload.Signature
  • Header:指定签名算法(如 HS256、RS256)和 Token 类型(JWT);
  • Payload:包含声明(claims),如用户 ID、角色、过期时间(exp)等;
  • Signature:对前两部分进行数字签名,确保 Token 未被篡改。

示例:

{
  "alg": "HS256",
  "typ": "JWT"
}
.
{
  "sub": "1234567890",
  "name": "Alice",
  "iat": 1516239022,
  "exp": 1516242622
}
.
HMACSHA256(base64UrlEncode(header)+'.'+base64UrlEncode(payload), secret)

为什么 JWT 被广泛使用?

  1. 无状态(Stateless) :服务器无需存储 Token,所有信息自包含,适合微服务和水平扩展;
  2. 跨域友好:可通过 HTTP Header 传递,不受 Cookie 同源策略限制;
  3. 标准化:社区支持广泛,几乎所有语言都有成熟库;
  4. 可携带丰富信息:Payload 可嵌入用户角色、权限等,减少数据库查询;
  5. 适用于移动端与 API 网关:易于集成到 OAuth 2.0、OpenID Connect 等协议中。

但需注意也存在如下缺点:

  • XSS 风险极高:若前端存在 XSS 漏洞,攻击者可直接窃取 localStorage 中的 Token;
  • 无法主动失效:除非引入黑名单机制,否则 Token 在过期前始终有效;
  • Token 过长:每次请求都需携带完整 Token,增加带宽开销。

因此,单纯依赖 localStorage 存储 Token 已被视为高风险做法。


Session vs Token:两种范式的对比

维度传统 Session现代 Token(JWT)
状态位置服务器有状态客户端有状态(Token 自包含)
扩展性需共享存储(如 Redis)无状态,天然水平扩展
安全性Session ID 不含敏感信息Token 一旦泄露即完全暴露
跨域支持Cookie 受 SameSite 限制Token 可自由携带
典型应用传统 Web 应用(如银行后台)SPA、移动端、微服务

二、现代混合认证架构的核心组件

2.1 Refresh Token + Access Token 双令牌机制

  • Access Token(AT) :短期有效的令牌(如 15 分钟),用于访问受保护资源。
  • Refresh Token(RT) :长期有效的令牌(如 7 天或 30 天),用于在 AT 过期后获取新的 AT。

这种设计实现了“短命 AT + 长命 RT”的安全模型:即使 AT 被泄露,其有效期极短,危害有限;而 RT 通常不用于直接访问业务接口,仅用于刷新,且可通过绑定设备/IP、设置使用次数等方式增强安全。

2.2 HttpOnly Cookie 存储 Refresh Token

为防止 XSS 窃取 RT,将 Refresh Token 存储在 HttpOnly Cookie 中是关键措施。

  • HttpOnly 属性:阻止 JavaScript 通过 document.cookie 读取该 Cookie,有效防御 XSS 攻击。
  • Secure 属性:确保 Cookie 仅通过 HTTPS 传输。
  • SameSite=Strict 或 Lax:缓解 CSRF 风险(尽管仍需配合其他措施)。

示例响应头:

Set-Cookie: refresh_token=abc123...; HttpOnly; Secure; SameSite=Strict; Path=/auth; Max-Age=2592000

注意:Cookie 的 Path 通常设为 /auth,限制其仅在认证相关接口生效,减少暴露面。

2.3 Access Token 存储于内存(In-Memory)

与 RT 不同,Access Token 不应持久化存储,而应保存在前端 JavaScript 的内存变量中(如 Vuex store、React state、或简单的全局变量)。

  • 优点

    • 页面刷新后自动清除,降低长期泄露风险;
    • 无法被 XSS 以外的攻击(如恶意扩展、本地脚本)轻易读取;
    • 符合“最小权限”原则——仅在当前会话中有效。
  • 挑战

    • 页面刷新会导致 AT 丢失,需通过 RT 自动刷新;
    • 需设计健壮的 Token 刷新与请求重试机制。

2.4 显式启用“凭证包含”(Credentials Inclusion)

由于 RT 存储在 Cookie 中,而现代浏览器默认在跨域请求中不携带 Cookie(出于安全考虑),前端必须显式启用凭证包含:

  • Fetch API:设置 credentials: 'include'

    fetch('/api/auth/refresh', {
      method: 'POST',
      credentials: 'include', // 关键!
      headers: { 'Content-Type': 'application/json' }
    });
    
  • Axios:设置 withCredentials: true

    axios.post('/api/auth/refresh', {}, { withCredentials: true });
    

同时,后端必须配置 CORS 响应头以允许携带凭证:

Access-Control-Allow-Origin: https://your-frontend.com
Access-Control-Allow-Credentials: true

⚠️ 注意:Access-Control-Allow-Origin 不能为 *,必须指定具体域名。


三、Cookie 中各个参数设置的重要性

Cookie 并非简单键值对,其安全性和行为高度依赖属性配置。以下是关键参数及其意义:

参数作用安全意义
HttpOnly禁止 JavaScript 读取 Cookie防御 XSS 窃取敏感 Cookie(如 Refresh Token)
Secure仅在 HTTPS 连接下发送 Cookie防止中间人攻击窃取 Cookie
SameSite控制跨站请求是否携带 Cookie • Strict:完全禁止跨站 • Lax:允许安全方法(GET)跨站缓解 CSRF 攻击
Path限定 Cookie 仅在指定路径下发送减少攻击面,例如 /auth 限制仅认证接口可用
Domain指定 Cookie 所属域名避免子域污染或越权访问
Max-Age / Expires设置 Cookie 过期时间避免长期有效的凭证滞留

错误配置示例

  • 缺少 HttpOnly → XSS 可直接读取 Refresh Token;
  • 缺少 Secure → HTTP 下明文传输,易被嗅探;
  • SameSite=None 但未设 Secure → 浏览器拒绝设置;
  • Path=/ → 所有接口都携带认证 Cookie,增加 CSRF 风险。

因此,Cookie 的安全 = 属性配置的严谨性


四、双重提交 Cookie 模式与 csrf_token

尽管 HttpOnly Cookie 能防 XSS,但无法防御 CSRF(跨站请求伪造) 。CSRF 攻击利用浏览器自动携带 Cookie 的特性,诱导用户在已登录状态下向目标站点发起恶意请求(如转账、改密码)。

4.1 什么是双重提交 Cookie(Double Submit Cookie)模式?

这是一种轻量级 CSRF 防护方案,其核心思想是:要求每个敏感请求同时提供两个相同的随机令牌——一个在 Cookie 中,一个在请求头或表单字段中

4.2 csrf_token 是什么?为什么需要它?

  • csrf_token 是一个由服务器生成的、高强度的随机字符串(如 128 位 UUID 或加密随机数)。

  • 服务器在用户首次访问时,通过普通 Cookie(非 HttpOnly)下发 csrf_token

    Set-Cookie: csrf_token=xyz789...; Path=/; Secure; SameSite=Lax
    
  • 前端 JavaScript 可读取该 Cookie,并在每次敏感请求(如 POST / PATCH / DELETE)中,将其放入自定义请求头(如 X-CSRF-Token):

    const csrfToken = document.cookie.split('; ').find(row => row.startsWith('csrf_token=')).split('=')[1];
    fetch('/api/change-password', {
      method: 'POST',
      headers: {
        'X-CSRF-Token': csrfToken,
        'Content-Type': 'application/json'
      },
      credentials: 'include'
    });
    
  • 服务器收到请求后,比对:

    • Cookie 中的 csrf_token
    • 请求头中的 X-CSRF-Token
    • 若两者一致,则认为请求合法。

为什么需要 csrf_token?

  1. 攻击者无法读取 Cookie:即使能诱导用户发起请求(如 <img src="https://bank.com/transfer?to=attacker">),也无法获取 csrf_token 的值(因跨域读取 Cookie 被同源策略阻止);
  2. 无需服务器存储:与传统同步令牌模式不同,双重提交无需在服务端保存 csrf_token,适合无状态架构;
  3. 兼容 SPA:前端可轻松读取并注入令牌。

⚠️ 注意:此方案要求 csrf_token Cookie 不能是 HttpOnly,否则前端无法读取。因此,该 Cookie 必须仅用于 CSRF 防护,绝不包含任何敏感信息


五、大平台为何在 Cookie 中存储部分用户信息?

许多大型平台(如 Google、GitHub、Shopify)在登录成功后,除了设置 HttpOnly 的 Refresh Token Cookie 外,还会设置一个非 HttpOnly 的 Cookie,其中包含部分用户信息(如 user_id、username、avatar_url 等)。这看似违背“最小暴露”原则,实则有其工程考量:

5.1 提升首屏加载性能

  • 若所有用户信息都需通过 API 获取,首屏渲染将延迟;
  • 将非敏感用户信息(如昵称、头像)写入普通 Cookie,前端可在 HTML 渲染阶段直接读取,实现“零请求”展示用户状态。

5.2 支持服务端渲染(SSR)与边缘计算

  • 在 Next.js、Nuxt.js 等 SSR 框架中,服务端需在渲染前知道用户身份;
  • 通过读取请求中的普通 Cookie(非 HttpOnly),服务端可快速识别用户并注入个性化内容。

5.3 安全边界清晰

  • 敏感信息(如权限、邮箱、手机号)绝不放入普通 Cookie
  • 仅存储公开或低风险字段(如 display_name);
  • 该 Cookie 通常设置 SameSite=LaxSecure,但仍可被 XSS 读取——因此内容必须经过严格过滤,且不可用于权限判断。

示例 Cookie:

user_info={"id":"u123","name":"Alice","avatar":"/img/alice.png"}; Path=/; Secure; SameSite=Lax

前端可安全使用该信息进行 UI 展示,但任何权限校验仍需依赖后端返回的 AT 或服务端 Session。


六、完整登录流程详解

以下是一个典型的登录-刷新-注销生命周期:

6.1 用户登录

  1. 用户提交用户名/密码至 /api/auth/login

  2. 后端验证凭据,生成:

    • Access Token(JWT,15 分钟有效期)
    • Refresh Token(随机字符串,30 天有效期,存入数据库并关联用户)
    • csrf_token(用于 CSRF 防护)
  3. 响应:

    • Body:返回 { access_token: "..." }

    • Headers:

      • Set-Cookie: refresh_token=...; HttpOnly; Secure; SameSite=Strict; Path=/auth
      • Set-Cookie: csrf_token=...; Secure; SameSite=Lax; Path=/
      • (可选)Set-Cookie: user_info=...; Secure; SameSite=Lax; Path=/
  4. 前端:

    • access_token 存入内存;
    • 读取 csrf_token Cookie,用于后续请求头注入。

6.2 请求受保护资源

  1. 前端发起请求至 /api/profile,携带:

    Authorization: Bearer <access_token>
    X-CSRF-Token: <csrf_token_from_cookie>
    
  2. 后端:

    • 验证 AT 签名与有效期;
    • 比对 Cookie 与 Header 中的 csrf_token;
    • 返回数据。

6.3 Access Token 过期处理

  1. 当 AT 过期,API 返回 401 Unauthorized
  2. 前端拦截该响应,自动调用 /api/auth/refresh(携带 Cookie);
  3. 后端验证 RT,签发新 AT;
  4. 前端更新内存中的 AT,并重试原请求。

6.4 用户登出

  1. 前端调用 /api/auth/logout

  2. 后端:

    • 删除 RT 记录;
    • 返回清除 Cookie 指令;
  3. 前端清空内存 AT。


七、总结

“Refresh Token + HttpOnly Cookie + 内存 Token + 凭证包含”架构代表了当前 Web 应用身份认证的最佳实践。它通过分层防御:

  • HttpOnly Cookie 防御 XSS 窃取长期凭证;
  • 内存 Token 限制短期凭证的暴露时间;
  • 显式 credentials inclusion 确保跨域场景下 Cookie 正确传递;
  • 双重提交 Cookie 模式 有效缓解 CSRF;
  • 大平台在 Cookie 中存储非敏感用户信息,是在安全与体验之间取得的合理平衡。