现代 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?
- HTTP 协议是无状态的:每次请求独立,服务器无法天然知道两次请求是否来自同一用户;
- 需要维持用户上下文:例如,用户登录后需记住其身份,以便授权访问个人资料、订单等;
- 安全性控制:通过 Session 可以实现登出、超时、多设备管理等功能;
- 个性化体验:如语言偏好、主题设置等可随会话持久化。
服务器端 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 被广泛使用?
- 无状态(Stateless) :服务器无需存储 Token,所有信息自包含,适合微服务和水平扩展;
- 跨域友好:可通过 HTTP Header 传递,不受 Cookie 同源策略限制;
- 标准化:社区支持广泛,几乎所有语言都有成熟库;
- 可携带丰富信息:Payload 可嵌入用户角色、权限等,减少数据库查询;
- 适用于移动端与 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: trueaxios.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 - 若两者一致,则认为请求合法。
- Cookie 中的
为什么需要 csrf_token?
- 攻击者无法读取 Cookie:即使能诱导用户发起请求(如
<img src="https://bank.com/transfer?to=attacker">),也无法获取csrf_token的值(因跨域读取 Cookie 被同源策略阻止); - 无需服务器存储:与传统同步令牌模式不同,双重提交无需在服务端保存 csrf_token,适合无状态架构;
- 兼容 SPA:前端可轻松读取并注入令牌。
⚠️ 注意:此方案要求
csrf_tokenCookie 不能是 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=Lax和Secure,但仍可被 XSS 读取——因此内容必须经过严格过滤,且不可用于权限判断。
示例 Cookie:
user_info={"id":"u123","name":"Alice","avatar":"/img/alice.png"}; Path=/; Secure; SameSite=Lax
前端可安全使用该信息进行 UI 展示,但任何权限校验仍需依赖后端返回的 AT 或服务端 Session。
六、完整登录流程详解
以下是一个典型的登录-刷新-注销生命周期:
6.1 用户登录
-
用户提交用户名/密码至
/api/auth/login; -
后端验证凭据,生成:
- Access Token(JWT,15 分钟有效期)
- Refresh Token(随机字符串,30 天有效期,存入数据库并关联用户)
- csrf_token(用于 CSRF 防护)
-
响应:
-
Body:返回
{ access_token: "..." } -
Headers:
Set-Cookie: refresh_token=...; HttpOnly; Secure; SameSite=Strict; Path=/authSet-Cookie: csrf_token=...; Secure; SameSite=Lax; Path=/- (可选)
Set-Cookie: user_info=...; Secure; SameSite=Lax; Path=/
-
-
前端:
- 将
access_token存入内存; - 读取
csrf_tokenCookie,用于后续请求头注入。
- 将
6.2 请求受保护资源
-
前端发起请求至
/api/profile,携带:Authorization: Bearer <access_token> X-CSRF-Token: <csrf_token_from_cookie> -
后端:
- 验证 AT 签名与有效期;
- 比对 Cookie 与 Header 中的 csrf_token;
- 返回数据。
6.3 Access Token 过期处理
- 当 AT 过期,API 返回
401 Unauthorized; - 前端拦截该响应,自动调用
/api/auth/refresh(携带 Cookie); - 后端验证 RT,签发新 AT;
- 前端更新内存中的 AT,并重试原请求。
6.4 用户登出
-
前端调用
/api/auth/logout; -
后端:
- 删除 RT 记录;
- 返回清除 Cookie 指令;
-
前端清空内存 AT。
七、总结
“Refresh Token + HttpOnly Cookie + 内存 Token + 凭证包含”架构代表了当前 Web 应用身份认证的最佳实践。它通过分层防御:
- HttpOnly Cookie 防御 XSS 窃取长期凭证;
- 内存 Token 限制短期凭证的暴露时间;
- 显式 credentials inclusion 确保跨域场景下 Cookie 正确传递;
- 双重提交 Cookie 模式 有效缓解 CSRF;
- 大平台在 Cookie 中存储非敏感用户信息,是在安全与体验之间取得的合理平衡。