19 KiB
tags, create time
| tags | 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 层面是怎么流转的:
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 配合:
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)
标准库:读写 Cookie
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 ID:完整流程
这是 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。只要后端登录接口种好了HttpOnlyCookie,后续所有fetch/axios请求会自动携带:// withCredentials 让跨域请求也带上 Cookie axios.defaults.withCredentials = true;前端唯一要做的就是确保请求配置正确,认证流程完全由浏览器 + 后端完成。
[!question] 为什么有时候浏览器"明明有 Cookie"却不发送? 常见原因排查清单:
- 跨域请求没设
credentials: "include"——同域自动带,跨域需要显式声明Domain设错了——比如后端在.example.com种了 Cookie,但前端跑在localhostSameSite=Strict且请求来自其他站点——Lax允许顶级导航,Strict连顶级导航也不发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 vs localStorage vs sessionStorage
| 维度 | Cookie | localStorage | sessionStorage |
|---|---|---|---|
| 容量 | ~4KB | ~5MB | ~5MB |
| 自动发送 | ✅ 每次请求自动携带 | ❌ | ❌ |
| 服务端可读 | ✅ | ❌ | ❌ |
| JS 访问 | 受限(HttpOnly 不可读) | ✅ | ✅ |
| 过期策略 | 可设过期时间 | 永久(手动删除) | 标签页关闭即失效 |
| 安全性 | HttpOnly + Secure 较高 | 易被 XSS 读取 | 易被 XSS 读取 |
[!info] 选型决策树
- 认证令牌 →
HttpOnly+SecureCookie(最安全)或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
Cookie vs JWT 传输
这不是二选一,而是两种组合策略:
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=StrictCookie,仅用于刷新 Access Token这样既享受了 JWT 的无状态优势,又利用了 Cookie 的安全特性。
关联笔记
- hhs/DEV/鉴权策略/JWT/JWT — JWT 令牌格式与认证流程
- JWT vs Cookie — JWT 与 Cookie 认证方案全景对比
- SameSite 与 CSRF 防护 — SameSite 属性详解与 CSRF 攻防