·约 12 分钟阅读·DeepSeek

安全 cookie 与 session 管理:HttpOnly、SameSite,以及它们在哪里失效

一份写给后端的实用安全 cookie 指南:HttpOnly、Secure、SameSite、Path、Domain 与 __Host- 前缀各自到底防住什么,为什么 cookie 既不是 session 也从不加密,session cookie 与 token cookie 在 CSRF 上的区别,以及那些经典错误——附真实的 curl 与浏览器抓包输出。

securitynginx

Web 上的每一次登录都以一个 Set-Cookie 响应头收尾,而我在 code review 里见到的几乎每一个,都少了至少一个「其实零成本就能加上」的属性。安全 cookie 不是事后补的功能——它就是那么几个属性,每一个都堵上一个具体的洞。下面是我做 session 管理 时用的模型:cookie 是什么、又不是什么;HttpOnly、Secure、SameSite 各自到底做了什么;session cookie 和 token cookie 在 CSRF 上的区别;以及那些反复出现在生产环境里的错误——其中头号就是 session fixation。

为了让讨论落到实处,我搭了一个一次性 HTTPS 服务器,绑在两个主机名上(site.test、evil.test,都指向 127.0.0.1),用 curl 和无头 Chrome 分别驱动。下面每一个响应头都是这台机器上的真实输出。

cookie 是浏览器存储、并自动附加到匹配请求上的一个小 name=value 对。大家最常搞错三件事:

  • 它不是 session。 cookie 只是一个标识符;会话状态在服务端(或在一枚签过名的 token 里)。除非服务端忘记了那条记录,否则删掉 cookie 并不会结束会话。
  • 它从不加密。 值是明文——在 devtools、日志、代理、以及 curl 自己的 cookie jar 里都看得见。HTTPS 只在传输过程中保护它。凡是不想让用户看到的东西,就别往 cookie 里放。
  • 它对用户可见、也可被用户修改。 没有魔法。下面是一个由 curl -c 写出的 jar,对应一个浏览器会标成 HttpOnly 的 cookie:
$ curl -sk --noproxy '*' --resolve site.test:8443:127.0.0.1 \
    -c jar.txt https://site.test:8443/set-lax -o /dev/null
$ cat jar.txt
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

#HttpOnly_site.test	FALSE	/	TRUE	1792404531	session	lax-tok-9f3a

这个 token 就明文躺在那里。HttpOnly 只把它对 JavaScript 隐藏——对用户、对磁盘、对代理都没有隐藏。把这个值当成 bearer 凭证,把每一个进来的 cookie 都当成不可信输入。

真正重要的那些属性

每个属性在某一层堵住一类攻击:

Set-Cookie: sid=… ; HttpOnly ; Secure ; SameSite=Lax ; Path=/ ; Max-Age=86400 HttpOnly JS 读不到 → 削弱 XSS 窃取 Secure 仅走 HTTPS → 削弱嗅探 SameSite 跨站发送规则 → 削弱 CSRF Path / Domain 谁收得到 → 限制影响范围 Max-Age 存活时长 → 缩短窗口
一个响应头,五件彼此独立的事:HttpOnly 与 Secure 是浏览器的存储与传输规则,SameSite 是请求规则,Path/Domain/Max-Age 是作用域与过期规则。没有一个是 session。

下面是一枚教科书式的 session cookie,从线上直接抓下来:

$ curl -skv --noproxy '*' --resolve site.test:8443:127.0.0.1 \
    https://site.test:8443/set-lax 2>&1 | sed -n '/^> GET/,/^< Content-Length/p'
> GET /set-lax HTTP/1.1
> Host: site.test:8443
...
< HTTP/1.1 200 OK
< Date: Mon, 12 Oct 2026 10:11:21 GMT
< Set-Cookie: session=lax-tok-9f3a; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=Lax
< Content-Type: text/plain; charset=utf-8
  • HttpOnly —— document.cookie 不会暴露它。这不能阻止 XSS;它阻止的是 XSS 偷走会话,也就是「页面被涂改」和「账号被完全接管」之间的差别。
  • Secure —— 只在 HTTPS 上发送;同一个 jar 换到明文 HTTP 重放,curl 和浏览器都会把它丢掉。
  • SameSite=Lax —— 会附加到顶层导航上,但不会附加到跨站 POST 和子资源请求上。对 session cookie 而言,这就是正确的默认值。
  • Path=/ 且不带 Domain —— 作用域限定在精确的主机上。这是刻意的;见下面的前缀规则。
  • Max-Age=604800 —— 以秒为单位的存活时长(Expires 是更老的绝对日期写法,两者同时出现时 Max-Age 生效)。两个都不写,就是一个随浏览器关闭而消失的 session cookie。
  • __Host- / __Secure- 前缀 —— 由浏览器强制执行的命名规则。__Secure-foo 必须带 Secure 设置;__Host-foo 必须 Secure、必须有 Path=/,而且 不能 带 Domain。违反规则,浏览器会静默丢弃这枚 cookie。

一个服务端发出一个合法、两个故意非法的带前缀 cookie,以及 Chrome 最终保留了哪些:

# 服务端响应(三个 Set-Cookie,一个合法)
< Set-Cookie: __Host-sid=host-tok; Path=/; HttpOnly; Secure; SameSite=Lax
< Set-Cookie: __Host-bad=host-bad; Path=/; Domain=site.test; Secure   # __Host- 带 Domain → 非法
< Set-Cookie: __Secure-bad=sec-bad; Path=/; SameSite=Lax              # __Secure- 不带 Secure → 非法

# Chrome 之后实际存下的 cookie(通过 CDP Network.getAllCookies 获取)
{"name":"session",     "domain":"site.test","path":"/","secure":true, "httpOnly":true, "sameSite":"Lax"}
{"name":"strict_sess", "domain":"site.test","path":"/","secure":true, "httpOnly":true, "sameSite":"Strict"}
{"name":"__Host-sid",  "domain":"site.test","path":"/","secure":true, "httpOnly":true, "sameSite":"Lax"}
{"name":"embed_tok",   "domain":"site.test","path":"/","secure":true, "httpOnly":false,"sameSite":"None"}

__Host-bad 和 __Secure-bad 都不见了,同样不见的还有一枚我故意不带 Secure 发出的 SameSite=None cookie。前缀让浏览器替你去证明 cookie 是带安全属性设置的:代码写错的结果是这枚 cookie 根本不存在,而不是它悄悄泄漏到所有子域。

两种形态占主导,而它们在 CSRF 上的差异恰恰是关键:

  • 服务端 session cookie。 cookie 里是一个不透明的 id;状态留在服务端,所以既好吊销又好轮换。因为浏览器会自动附加它,它天生暴露于 CSRF——所以既需要 SameSite,也需要一枚 CSRF token。
  • token cookie(cookie 里的 JWT)。 自包含、无状态,但吊销是难点:一枚签名的 token 在过期前一直有效,除非你维护一份黑名单——那你就又回到了 session 表。它仍然是 cookie,浏览器仍然会自动附加,CSRF 依然适用。
  • localStorage 里的 token。 它不是 cookie,浏览器从不自动附加它,经典 CSRF 也随之消失——但任何 XSS 都能读到它,而 HttpOnly 保护不了它:你用 CSRF 问题换来一个更大的 XSS 问题。

我的原则是:session id 放进 HttpOnly cookie,状态放服务端以便吊销,状态变更请求配一枚 CSRF token,token 放内存而不是 localStorage。攻击机制我在 写给后端工程师的 XSS 与 CSRF 里展开过。

SameSite 到底防住了什么

SameSite 控制浏览器是否把 cookie 附加到一个 跨站 请求上——这里的「站(site)」比「源(origin)」更宽:它是可注册域名加 scheme,所以 app.example.com 和 api.example.com 尽管是不同的源,却是 同一个站。

  • Lax —— 在顶层导航(点链接)时发送,跨站 POST 和子资源请求时不发送。当前浏览器的默认值。
  • Strict —— 任何跨站情况都不发送,包括顶层导航。最强,但用户从外部链接进来时会被登出。
  • None —— 总是发送;必须配 Secure,否则浏览器会丢弃。

微妙之处在于 谁 在强制执行它。SameSite 是浏览器策略,不是服务端策略——所以非浏览器客户端完全无视它。下面 curl 一边如实告诉服务端请求是跨站的,一边照旧重放那枚 SameSite=Strict cookie:

$ curl -skv --noproxy '*' --resolve site.test:8443:127.0.0.1 -b strictjar.txt \
    -H 'Origin: https://evil.test:8444' -H 'Sec-Fetch-Site: cross-site' \
    https://site.test:8443/echo 2>&1 | grep -E '^(<|>) '
> GET /echo HTTP/1.1
> Host: site.test:8443
> Cookie: strict_sess=str-tok
> Origin: https://evil.test:8444
> Sec-Fetch-Site: cross-site

cookie 还是发出去了——你的服务端没法强制 SameSite,只有浏览器能。那浏览器怎么做?在 Chrome 里,先访问 site.test 并设置 Lax、Strict、None 三枚 cookie,然后加载 evil.test:

# evil.test 向 site.test 发起一个跨站 fetch(),随后导航过去。
# 下面是 site.test 实际收到的 Cookie 头:

# 跨站子资源(fetch,credentials:'include'):
GET /echo  Origin='https://evil.test:8444'  Sec-Fetch-Site='cross-site'  Cookie='embed_tok=none-tok'  count=1
# → 只有 SameSite=None 的 cookie 到达;Lax 和 Strict 都被扣下

# 跨站顶层导航(location.href):
GET /echo  Origin='None'  Sec-Fetch-Site='cross-site'  Cookie='session=lax-tok-9f3a; __Host-sid=host-tok; embed_tok=none-tok'  count=3
# → Lax 通过了;SameSite=Strict 即便在这里也被扣下

两行就是全部模型:导航时 Lax、跨站永不 Strict、None 到处都发。

SameSite 不做的事:

  • 它不跨子域。 SameSite=Strict 把 evil.yourdomain.example 当作 同站。只要有租户、用户内容或一个被攻陷的子域挂在你的可注册域名下,来自那里的请求就算同站,cookie 照样被附上。
  • 它挡不住同站 CSRF,也就是攻击者控制了一个同站端点的情况。
  • 它不能替代 CSRF token。 两者在常见的跨站 POST 上重叠,但证明用户 意图 的是 token——两个都留。

那些经典错误

  1. session id 放 localStorage。 无法 HttpOnly;任何 XSS 都能读。放进 cookie。
  2. 登录时不轮换。 登录前的 id 在认证之后依然有效——这正是 session fixation 的温床。
  3. cookie 不带 Secure。 一次 HTTP 请求、一个 HSTS 缺口、或公共 Wi-Fi 上的一次跳转,凭证就上了线。
  4. Domain 太宽。 Domain=.example.com 把会话交给 每一个 子域,包括那个年久失修的 WordPress。省略它,就只留在精确的主机上。
  5. 登出时不做服务端失效。 删 cookie 只清了客户端;记录若仍有效,一份被偷走的副本照样能用。
  6. 把数据存进 cookie 而不是存 id。 它让每个请求都变胖,而且因为 cookie 不加密,把载荷暴露给用户和沿途任何日志。

session fixation,以及为什么要在权限变更时轮换 id

session fixation(会话固定)正是这些属性本身覆盖不了的攻击。攻击者设法让受害者的浏览器使用一个攻击者已经知道的 session id——通过一个链接、一个由他控制的页面发出的 Set-Cookie,或一个登录前的端点——然后等待。当受害者登录时,服务端 把同一个 id 提升 为已认证会话,于是攻击者手握一个有效的 session id,却从未见过密码。

修法只有一条规则:在每一次权限变更时重新生成 session id——登录、登出、改密码、提权。签发一个全新的服务端 id,并让旧的失效:

import secrets

def log_in(session_store, old_id, user):
    # 绝不复用登录前的 id:删掉它,另发一个新的。
    session_store.delete(old_id)
    new_id = secrets.token_urlsafe(32)          # 256 位,不可预测
    session_store.create(new_id, user_id=user.id, csrf=secrets.token_urlsafe(32))
    return new_id

把 id 存在服务端,才让轮换变得廉价、让吊销成为可能。无状态 token 没有 delete()——这就是无状态的真正代价。

除非有特别理由,我默认这么设:

# session cookie:属性的重要程度不亚于值本身。
response.set_cookie(
    "__Host-sid",              # 前缀 → 浏览器强制 Secure + Path=/ + 无 Domain
    new_id,
    httponly=True,             # JavaScript 读不到
    secure=True,               # 绝不明文 HTTP 发送
    samesite="Lax",            # 跨站 POST 与子资源不带它
    path="/",                  # __Host- 前缀要求
    max_age=60 * 60 * 24 * 7,  # 显式的存活时长
)
  • 把它命名为 __Host-sid,让浏览器替你拒绝任何配置错误。
  • session cookie 用 HttpOnly + Secure + SameSite=Lax;只有在登录流程能容忍时才用 Strict。
  • 省略 Domain,让 cookie 只属于主机,绝不跨子域共享。
  • 轮换 id:登录、登出、权限变更时都换;session 存服务端,以便失效。
  • 显式设置 Max-Age,并通过轮换而非无限续期来刷新。
  • 为状态变更请求加上 CSRF token,用常量时间比较——SameSite 是很强的默认值,但不是完整防御。
  • 全部走 HTTPS 并启用 HSTS,再配上一套严格的 Content-Security-Policy 与其余安全响应头。
# Secure 标志的实际表现:同一个 jar,换成明文 HTTP——curl 拒绝附加它。
> GET /echo HTTP/1.1
> Host: site.test:8099
> User-Agent: curl/8.22.0
> Accept: */*
# 没有发送任何 Cookie 头;服务端报告 cookie 字符串为空
received=''
count=0

浏览器同样不会在 http:// 上发送 Secure cookie——这个标志只有在「每一个需要它的请求都已在 HTTPS 上」时才保护得了凭证。

HttpOnly、Secure、SameSite、前缀、服务端轮换,全都便宜且彼此独立。默认就把它们设上,权限变更时轮换 id,别再在每次登录时把你的薄弱环节发出去。