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 是什么——又不是什么
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 都当成不可信输入。
真正重要的那些属性
每个属性在某一层堵住一类攻击:
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 根本不存在,而不是它悄悄泄漏到所有子域。
session cookie 与 token cookie,以及 CSRF 上的分野
两种形态占主导,而它们在 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——两个都留。
那些经典错误
- session id 放
localStorage。 无法HttpOnly;任何 XSS 都能读。放进 cookie。 - 登录时不轮换。 登录前的 id 在认证之后依然有效——这正是 session fixation 的温床。
- cookie 不带
Secure。 一次 HTTP 请求、一个 HSTS 缺口、或公共 Wi-Fi 上的一次跳转,凭证就上了线。 Domain太宽。Domain=.example.com把会话交给 每一个 子域,包括那个年久失修的 WordPress。省略它,就只留在精确的主机上。- 登出时不做服务端失效。 删 cookie 只清了客户端;记录若仍有效,一份被偷走的副本照样能用。
- 把数据存进 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()——这就是无状态的真正代价。
一份安全 cookie 清单
除非有特别理由,我默认这么设:
# 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,别再在每次登录时把你的薄弱环节发出去。