Files
2026-05-24 11:42:38 +08:00

19 KiB
Raw Permalink Blame History

tags, create time
tags create time
Cookie
HTTP
session
web-security
authentication
backend
frontend
2026-05-22 10:00

Cookie — HTTP 状态管理机制

概述

Cookie 是浏览器提供的一种客户端状态存储机制,最初由 Netscape 在 1994 年提出,现由 RFC 6265 标准化。它的核心使命很简单:让无状态的 HTTP 协议能够"记住"用户——浏览器在每次请求时自动附带 Cookie,服务端据此识别身份、维持会话。

[!tip] Cookie ≠ Session ≠ JWT 这三个概念经常被混为一谈,但它们解决的是不同层面的问题:

  • Cookie:存储与传输机制(浏览器行为)
  • Session:服务端会话管理策略
  • JWT:令牌数据格式(一种数据结构)

三者可以任意组合,也可以独立使用。

[!question] 思考 如果 HTTP 天生无状态,那"登录后跳转页面不用重新登录"这个体验是怎么实现的? —— 答案就是 Cookie。登录成功后服务端通过 Set-Cookie 响应头在浏览器种下一个标识,后续每次请求浏览器自动带上这个标识,服务端一看就知道"哦,是你"。

结构

一个 Cookie 本质上就是一个 键值对,附带一组控制其行为的属性:

name=value; Domain=example.com; Path=/; Expires=Thu, 01 Jan 2027 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax
属性 说明 默认值
name=value 键值对,Cookie 的核心数据 —
Domain Cookie 所属域名,子域自动继承 当前域
Path Cookie 生效的 URL 路径前缀 /
Expires / Max-Age 过期时间或存活时长 会话结束即失效
HttpOnly 禁止 JavaScript 访问 false
Secure 仅在 HTTPS 下传输 false
SameSite 跨站请求限制策略 浏览器各异(见子文档)

[!warning] 大小限制 单个 Cookie 的大小上限约 4KB(含所有属性),每个域名下通常限制 ~50 个 Cookie。如果需要存储大量数据,应该用服务端 Session 或数据库,而不是往 Cookie 里塞。

类型 持久性 典型场景
会话 Cookie 浏览器关闭即删除(无 Expires/Max-Age) 临时状态、一次性提示
持久 Cookie 存活至过期时间 "记住我"、用户偏好

Cookie 名称可以带前缀来强制约束安全属性。浏览器看到这些前缀时,会校验对应属性是否满足要求,不满足则直接拒绝写入——相当于"编译期"安全检查。

前缀 强制要求 效果
__Secure- 必须设 Secure(HTTPS) 确保该 Cookie 永远不会通过 HTTP 明文传输
__Host- 必须设 Secure + Path=/ + 不能设 Domain 除了 HTTPS 限制外,还锁定到当前主机,防止子域覆盖攻击
# 合法
Set-Cookie: __Host-session_id=abc123; Secure; Path=/

# 非法(设了 Domain,浏览器拒绝)
Set-Cookie: __Host-session_id=abc123; Secure; Path=/; Domain=example.com

# 非法(没设 Secure,浏览器拒绝)
Set-Cookie: __Secure-token=xyz; Path=/

[!question] 为什么 __Host- 禁止设 Domain? 设了 Domain=.example.com 后,子域 evil.example.com 可以覆盖掉 example.com 上的同名 Cookie——这是子域 Cookie 注入攻击。__Host- 前缀通过禁止 Domain 来彻底杜绝此类问题。

[!tip] 前缀是防御纵深的一部分 即使你的代码没写 Secure,只要用了 __Host- 前缀,浏览器会帮你挡住。在团队协作中,这是一种"防呆"机制——不是每个人都记得加安全属性,但前缀可以强制保证。

HTTP 流程

理解 Cookie 的关键是搞清楚它在 HTTP 层面是怎么流转的:

sequenceDiagram
    participant B as Browser
    participant S as Server

    Note over B: 首次访问(无 Cookie)
    B->>S: GET /login
    S-->>B: 200 OK + Set-Cookie: session_id=abc123; HttpOnly; Secure

    Note over B: 浏览器自动存储 Cookie
    B->>B: 存储 session_id=abc123

    Note over B: 后续请求自动携带
    B->>S: GET /dashboard (Cookie: session_id=abc123)
    S->>S: 读取 Cookie,识别用户
    S-->>B: 200 OK(个性化内容)

    Note over B: 登出,服务端清除 Cookie
    B->>S: POST /logout
    S-->>B: 200 OK + Set-Cookie: session_id=; Max-Age=0
    B->>B: 浏览器删除 Cookie

[!note] 关键机制 Cookie 的核心设计在于浏览器自动管理:收到 Set-Cookie 后自动存储,后续请求同域名时自动附加到 Cookie 头——开发者不需要手动处理传输。这是它和 Authorization 头(JWT 常用方式)的根本区别。

在前后端分离的架构中,前端 api.example.com 请求后端 backend.example.com 是跨域请求。浏览器的同源策略默认不允许跨域发送 Cookie,需要 CORS 配合:

sequenceDiagram
    participant F as "前端 api.example.com"
    participant B as "后端 backend.example.com"

    Note over F: "fetch credentials include"
    F->>B: "OPTIONS /api/data"
    Note over B: "Access-Control-Allow-Origin: https://api.example.com"
    Note over B: "Access-Control-Allow-Credentials: true"
    B-->>F: "204 No Content"

    F->>B: "GET /api/data Cookie: session_id=abc123"
    B-->>F: "200 OK"

服务端关键响应头:

Access-Control-Allow-Origin: https://api.example.com   # 不能用 *,必须指定域名
Access-Control-Allow-Credentials: true                   # 允许携带凭证

前端对应的请求配置:

// fetch API
fetch("https://backend.example.com/api/data", {
  credentials: "include",  // 关键:跨域请求也带 Cookie
});

// axios 全局配置
axios.defaults.withCredentials = true;

[!warning] Allow-Origin: * 和 Allow-Credentials: true 不能同时出现 当 credentials: "include" 时,Access-Control-Allow-Origin 不能设为通配符 *——这是 CORS 规范的硬性约束。服务端必须回显请求头中的 Origin 值(注意:只允许白名单中的域)。

[!question] 为什么 localhost:3000 请求 localhost:8080 也报 CORS 错误? 浏览器的同源策略看的是 协议 + 域名 + 端口 三元组。即使域名相同,端口不同(3000 vs 8080)也算跨域。开发环境的 proxy(如 Vite 的 server.proxy)之所以能解决这个问题,是因为它把请求代理到了同一个源,请求根本没离开浏览器的同源范围。

服务端操作(Go)

import "net/http"

// 写入 Cookie
func setCookieHandler(w http.ResponseWriter, r *http.Request) {
    http.SetCookie(w, &http.Cookie{
        Name:     "session_id",
        Value:    "abc123",
        Path:     "/",
        Domain:   "example.com",
        MaxAge:   3600 * 24, // 24 小时
        HttpOnly: true,      // 防 XSS
        Secure:   true,      // 仅 HTTPS
        SameSite: http.SameSiteLaxMode,
    })
}

// 读取 Cookie
func getCookieHandler(w http.ResponseWriter, r *http.Request) {
    cookie, err := r.Cookie("session_id")
    if err != nil {
        http.Error(w, "session not found", http.StatusUnauthorized)
        return
    }
    // cookie.Value 即为 "abc123"
}

// 删除 Cookie(将 MaxAge 设为负数)
func deleteCookieHandler(w http.ResponseWriter, r *http.Request) {
    http.SetCookie(w, &http.Cookie{
        Name:   "session_id",
        Value:  "",
        MaxAge: -1, // 立即删除
    })
}

[!tip] 读取失败的常见原因 r.Cookie() 返回 http.ErrNoCookie 时,说明该 Cookie 不存在。但更隐蔽的问题是:Cookie 设了 Domain=".example.com" 但你在 localhost 测试——跨域的 Cookie 不会被浏览器存储。开发环境记得不要设 Domain。

Gin 框架

Gin 对 Cookie 做了简单封装:

// 写入
c.SetCookie("session_id", "abc123", 86400, "/", "example.com", true, true)

// 读取
val, err := c.Cookie("session_id")
if err != nil {
    c.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"})
    return
}

// 删除
c.SetCookie("session_id", "", -1, "/", "", false, false)

[!question] 标准库 vs Gin,什么时候用哪个? 如果你用的是 Gin 框架,当然直接用 c.SetCookie() / c.Cookie(),没必要引入标准库的 http.SetCookie。但如果你用的是其他框架(Echo、Fiber、Chi),它们对 Cookie 的封装方式各不相同,而底层都依赖 net/http 的 http.Cookie 结构体——掌握标准库的写法,换个框架也能无缝上手。

这是 Cookie 最经典的用法——配合服务端 Session:

flowchart TD
    A["用户登录 POST login"] --> B{"凭据正确"}
    B -->|"是"| C["生成 session_id = random UUID"]
    C --> D["存储到 Redis, session_id 映射用户信息"]
    D --> E["Set-Cookie: session_id=xxx; HttpOnly"]
    E --> F["返回登录成功"]

    B -->|"否"| G["返回 401"]

    H["后续请求 GET api/user"] --> I["浏览器自动带 Cookie"]
    I --> J["读取 session_id"]
    J --> K{"Redis 中存在"}
    K -->|"是"| L["取出用户信息, 放行"]
    K -->|"否"| M["返回 401"]

Go + Redis 实现:

import "github.com/google/uuid"

func LoginHandler(c *gin.Context) {
    // ... 校验用户名密码 ...

    // 生成 Session ID
    sessionID := uuid.New().String()

    // 存入 Redis,设置过期时间
    redis.Set(ctx, "session:"+sessionID, userID, 24*time.Hour)

    // 种到 Cookie
    c.SetCookie("session_id", sessionID, 86400, "/", "", true, true)
    c.JSON(200, gin.H{"message": "login success"})
}

func AuthMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        sessionID, err := c.Cookie("session_id")
        if err != nil {
            c.AbortWithStatusJSON(401, gin.H{"error": "no session"})
            return
        }

        userID, err := redis.Get(ctx, "session:"+sessionID).Result()
        if err != nil {
            c.AbortWithStatusJSON(401, gin.H{"error": "session expired"})
            return
        }

        c.Set("userID", userID)
        c.Next()
    }
}

客户端操作(React / TypeScript)

[!warning] 不要用 JS 操作 HttpOnly Cookie HttpOnly 标记的 Cookie 无法被 document.cookie 读取——这正是它的安全价值。前端代码中能读写的只有非 HttpOnly 的 Cookie(如语言偏好、主题设置等)。涉及认证的 Cookie 应始终设为 HttpOnly,由后端控制。

// 读取非 HttpOnly 的 Cookie(如用户偏好)
function getCookie(name: string): string | null {
  const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
  return match ? decodeURIComponent(match[2]) : null;
}

// 写入 Cookie
function setCookie(name: string, value: string, days: number) {
  const expires = new Date(Date.now() + days * 864e5).toUTCString();
  document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`;
}

// 删除 Cookie
function deleteCookie(name: string) {
  document.cookie = `${name}=; Max-Age=0; path=/`;
}

[!note] 前端认证的典型模式 如果使用 Cookie 做认证(而非 Authorization 头 + JWT),前端不需要手动操作认证 Cookie。只要后端登录接口种好了 HttpOnly Cookie,后续所有 fetch / axios 请求会自动携带:

// withCredentials 让跨域请求也带上 Cookie
axios.defaults.withCredentials = true;

前端唯一要做的就是确保请求配置正确,认证流程完全由浏览器 + 后端完成。

[!question] 为什么有时候浏览器"明明有 Cookie"却不发送? 常见原因排查清单:

  1. 跨域请求没设 credentials: "include"——同域自动带,跨域需要显式声明
  2. Domain 设错了——比如后端在 .example.com 种了 Cookie,但前端跑在 localhost
  3. SameSite=Strict 且请求来自其他站点——Lax 允许顶级导航,Strict 连顶级导航也不发
  4. Secure 但用了 HTTP——本地开发最容易踩的坑

这些问题在开发环境中很常见,尤其当后端用 .localhost 或 IP 地址时。遇到"Cookie 消失"时,打开 DevTools → Application → Cookies 逐项检查属性,比看代码有效 10 倍。

安全机制

Cookie 的安全问题本质上围绕两个攻击面:XSS(跨站脚本) 和 CSRF(跨站请求伪造)。

防御 XSS:HttpOnly + Secure

flowchart LR
    subgraph XSS_Attack["XSS 攻击流程"]
        A["恶意脚本注入页面"] --> B["JS 读取认证 Cookie"]
        B --> C["发送到攻击者服务器"]
    end

    subgraph Defense["防御"]
        D["HttpOnly, JS 无法读取 Cookie"]
        E["Secure, 仅 HTTPS 传输"]
        F["输入转义, 从源头阻止注入"]
    end
属性 防御效果 设置建议
HttpOnly 阻止 JS 读取,XSS 无法窃取 Cookie 认证类 Cookie 必须 设置
Secure 仅 HTTPS 传输,防止中间人截获 生产环境 必须 设置
CSP 头 限制页面可执行的脚本来源 配合使用,多一层防护

防御 CSRF

CSRF 的本质:浏览器会自动带上目标域的 Cookie,攻击者借此伪造用户请求。

详细的 SameSite 属性解析和 CSRF 攻防策略,见 → SameSite 与 CSRF 防护

[!question] 为什么 JWT + Authorization 天然防 CSRF? 因为 Authorization 头不是浏览器自动发送的——它需要 JS 代码显式设置。而 CSRF 攻击依赖的是浏览器自动携带 Cookie 这一行为。没有自动携带,就没有 CSRF。但代价是:JWT 需要前端手动管理令牌的存储和注入。

Partitioned Cookie(CHIPS)

自 2024 年起,Chrome 逐步淘汰第三方 Cookie(即跨站 Cookie),这一趋势已在 2025-2026 年全面落地。它直接影响嵌入式场景:支付回调、第三方登录、<iframe> 嵌入等。

问题本质:在 site-a.com 的页面里嵌入 analytics.com 的资源,浏览器会带上 analytics.com 的 Cookie——这种跨站携带行为既支持了合法场景,也被用于跨站追踪。

解决方案:Partitioned 属性,让 Cookie 按顶级站点隔离存储:

# 传统方式:analytics.com 的 Cookie 在所有嵌入它的站点间共享
Set-Cookie: track_id=abc123; SameSite=None; Secure

# Partitioned:Cookie 按 site-a.com 和 site-b.com 分别隔离
Set-Cookie: track_id=abc123; SameSite=None; Secure; Partitioned
flowchart LR
    subgraph Traditional["传统第三方 Cookie"]
        A["site-a.com 嵌入 analytics.com"] --> C["共享同一份 Cookie"]
        B["site-b.com 嵌入 analytics.com"] --> C
    end

    subgraph Partitioned["Partitioned Cookie (CHIPS)"]
        D["site-a.com 嵌入 analytics.com"] --> E["独立 Cookie 分区 site-a.com"]
        F["site-b.com 嵌入 analytics.com"] --> G["独立 Cookie 分区 site-b.com"]
    end

[!tip] 什么时候需要关注 CHIPS? 如果你的系统有跨站 Cookie 需求(如 SSO 单点登录、嵌入式支付、跨站 <iframe>),且目标用户使用 Chrome 115+,就必须加上 Partitioned。同站应用不受影响。这是向后兼容的——不支持的浏览器会忽略这个属性。

对比与选型

维度 Cookie localStorage sessionStorage
容量 ~4KB ~5MB ~5MB
自动发送 ✅ 每次请求自动携带 ❌ ❌
服务端可读 ✅ ❌ ❌
JS 访问 受限(HttpOnly 不可读) ✅ ✅
过期策略 可设过期时间 永久(手动删除) 标签页关闭即失效
安全性 HttpOnly + Secure 较高 易被 XSS 读取 易被 XSS 读取

[!info] 选型决策树

  • 认证令牌 → HttpOnly + Secure Cookie(最安全)或 Authorization 头 + 内存存储(次安全)
  • 用户偏好设置(语言、主题)→ 普通 Cookie 或 localStorage
  • 临时表单数据 → sessionStorage
  • 大量缓存数据 → localStorage(注意容量上限)

Session vs JWT:认证架构选型

维度 Session + Cookie JWT(无状态令牌)
状态存储 服务端(Redis / DB) 客户端(令牌自包含)
水平扩展 需要共享 Session 存储(Redis 集群) 天然支持——任何节点都能独立验签
吊销能力 直接删除 Session 即失效 难——Token 签发后无法主动撤销(需黑名单)
服务端开销 每次请求查 Redis 只需 CPU 验签(极轻量)
负载大小 Cookie 仅传 ~36B UUID JWT 通常 500B~1KB(含 payload + 签名)
适用场景 传统 Web、对吊销要求高 微服务、跨域 API、移动端

[!question] JWT "无状态" 是真的无状态吗? 严格来说,JWT 只是不需要服务端存储会话数据,但它仍然有状态——用户角色变更了怎么办?Token 被盗了怎么办?这些场景下你需要维护一个黑名单(通常存在 Redis 里),这时 JWT 又变成了"有状态"的。

所以"无状态"是一种理想模型。在生产环境中,纯无状态 JWT 很少见,通常还是会搭配一个轻量级的 Token 黑名单或版本号机制。不要被"无状态"的字面意思误导了。

flowchart TD
    A["认证需求"] --> B{"需要随时强制下线"}
    B -->|"是"| C["Session + Cookie"]
    B -->|"否"| D{"微服务, 跨域多"}
    D -->|"是"| E["JWT + Authorization 头"]
    D -->|"否"| F{"需要极低延迟"}
    F -->|"是"| E
    F -->|"否"| C

    C --> C1["Redis 存 Session"]
    E --> E1["签名验签"]
    E --> E2["可选: Redis 黑名单"]

    style C fill:#e8f4f8
    style E fill:#fde8e8

这不是二选一,而是两种组合策略:

flowchart TD
    A["认证方案选择"] --> B{"你的架构"}
    B -->|"前后端分离 SPA"| C["JWT + Authorization 头"]
    B -->|"传统 Web, SSR"| D["Session ID + Cookie"]

    C --> C1["前端手动管理令牌"]
    C --> C2["天然防 CSRF"]
    C --> C3["需防 XSS 窃取 localStorage"]

    D --> D1["浏览器自动管理"]
    D --> D2["需防 CSRF 攻击"]
    D --> D3["HttpOnly 防 XSS"]

    style C fill:#e8f4f8
    style D fill:#fde8e8

[!tip] 最佳实践:两者结合 现代生产环境最常用的策略是混合模式:

  • Access Token(JWT):短生命周期(15 分钟),存 Authorization 头,用于 API 调用
  • Refresh Token:长生命周期(7 天),存 HttpOnly + Secure + SameSite=Strict Cookie,仅用于刷新 Access Token

这样既享受了 JWT 的无状态优势,又利用了 Cookie 的安全特性。

关联笔记