--- tags: [Cookie, HTTP, session, web-security, authentication, backend, frontend] create time: 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 的本质 ### 结构 一个 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 vs 持久 Cookie | 类型 | 持久性 | 典型场景 | |------|--------|----------| | **会话 Cookie** | 浏览器关闭即删除(无 `Expires`/`Max-Age`) | 临时状态、一次性提示 | | **持久 Cookie** | 存活至过期时间 | "记住我"、用户偏好 | ### Cookie 前缀:`__Host-` 与 `__Secure-` 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 层面是怎么流转的: ```mermaid 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 常用方式)的根本区别。 ### 跨域 Cookie 与 CORS 在前后端分离的架构中,前端 `api.example.com` 请求后端 `backend.example.com` 是**跨域请求**。浏览器的同源策略默认不允许跨域发送 Cookie,需要 CORS 配合: ```mermaid 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 # 允许携带凭证 ``` 前端对应的请求配置: ```typescript // 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) ### 标准库:读写 Cookie ```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 做了简单封装: ```go // 写入 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 ID:完整流程 这是 Cookie 最经典的用法——配合服务端 Session: ```mermaid 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 实现: ```go 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`,由后端控制。 ```typescript // 读取非 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` 请求会自动携带: > > ```typescript > // 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 ```mermaid 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 年全面落地。它直接影响嵌入式场景:支付回调、第三方登录、`