密码是对的,人却登不进去:一次 Secure Cookie 的排查记录

后台登录接口返回成功,紧接着的请求却像没登录一样被弹回登录页。查完 Set-Cookie,再用严格客户端做了一次对照实验,才确认根因是 Secure 属性在 http 上不回传,而 curl 因为 loopback 例外一直没暴露它。
现象:密码是对的,人却登不进去
线上部署完,第一件事是登录后台确认能发文章。我输入用户名和密码,页面转了一圈,回到登录页。
再输一次,还是一样。换浏览器、清缓存、确认密码里没有多余空格,都没用。
同一个密码,本地开发环境一次就进去了。
这里有个容易走错的岔路:撞见这种画面,第一反应往往是"是不是密码记错了"。但密码错的话,后台会明确告诉你密码错。它现在什么都没说,只是把我弹回去,像什么都没发生过。这两种情况必须分开。
第一步:分清「认证失败」和「会话没被认出来」
这两种失败在前端长得一模一样,都是弹回登录页,但根因完全不同。
- 认证失败:用户名或密码不对,后端返回 401,并且不会创建任何会话
- 会话丢失:登录接口返回成功,会话也建好了,但后续请求没带上凭据,后端同样只能回 401
分辨的办法是看登录接口自己的响应。我打开浏览器开发者工具的 Network 面板,找到那条登录请求,状态码 200,响应体是 {"ok":true,"data":{...},"message":"登录成功"}。
密码是对的,后端也把票据发下来了。问题在于这张票没被用上。
第二步:看登录响应的响应头
还是那条请求,切到响应头:
set-cookie: tiana_admin_session=eyJ1c2VybmFtZSI...; HttpOnly; Max-Age=2592000; Path=/; SameSite=lax; Secure末尾那个 Secure 是关键。
再往下看紧跟其后的一条请求,前端登录成功后会立刻拉一次用户信息。这条请求的请求头里,没有 Cookie。后端读不到会话,按未登录处理,回了 401,前端就把我踢回登录页。
链路到这里就闭合了:cookie 发出去了,但没有回来。
根因:Secure 只在安全通道上回传
RFC 6265 对 Secure 的定义是,带这个属性的 Cookie 只允许在安全通道上传输。浏览器认定 https 是安全通道,也把 localhost 这一类地址当作可信上下文。用 http 加普通域名访问时,就算服务端把 Set-Cookie 发了下去,浏览器也不会在后续请求里带上它。
最难受的是它不报错。控制台没有警告,网络面板里也没有失败请求,它就是安静地不带。你能观察到的现象只有一句:登录了,等于没登。
一次对照实验
为了确认不是自己看花眼,我写了个最小复现,让同一个服务分别在两个环境变量下响应,再用一个严格按标准实现的客户端去访问。
import http.cookiejar
import urllib.request
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
opener.open(urllib.request.Request("http://127.0.0.1:8137/login", method="POST"))
for c in jar:
print(c.name, "secure =", c.secure)
resp = opener.open(urllib.request.Request("http://127.0.0.1:8137/whoami", method="POST"))
print(resp.read().decode())两种环境的输出只差一个属性,结果却完全相反:
# BLOG_ENV=production
tiana_admin_session secure = True
{"cookie":"","env":"production"}
# BLOG_ENV=development
tiana_admin_session secure = False
{"cookie":"demo-token-abc","env":"development"}production 下,cookie 被存进了 jar(属性上是 secure),但复访时不回传,后端拿到的 cookie 是空的。development 下没有 Secure 属性,一路正常。
为什么 curl 测不出来
这个坑最阴的地方在这里。我一开始是用 curl 在本地验证的,怎么测都正常,cookie 照样带回来。
原因是 curl 底层的 libcurl 对 loopback 地址有例外处理,127.0.0.1 和 localhost 被当成安全上下文。所以我拿 curl 打本地服务,永远看不出这个问题。
而线上我用的是 http 加域名,域名不在这个例外里,Secure 的规则立刻生效。
本地能过,线上翻车,通常就是这种差。
改法:让后台走 https,而不是关掉 Secure
正解很直白:后台用 https 访问。生产环境本来就该全站 https,后台更是。证书还没下来的时候,先把 http 那一跳强制跳过去:
server {
listen 80;
server_name www.tiana.com.cn;
location / {
return 301 https://$host$request_uri;
}
}证书就位之后,这段不是临时补丁,而是长期配置。
还有一处反代场景的坑要一起处理。nginx 终止了 TLS,转发给后端的其实是明文 http。后端如果据此判断「当前不是 https」,可能会在 cookie 或跳转上做出错误决策。把真实协议透传过去:
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
}uvicorn 这一侧也要愿意相信这个头:
uvicorn backend.app.main:app --host 127.0.0.1 --port 8000 --proxy-headers --forwarded-allow-ips=127.0.0.1我为什么没直接关掉 Secure
最容易想到的解法是把 secure 写死成 False,或者按环境开关,开发环境关掉。我没这么做。
会话 cookie 就是一张能进后台的票据。用 http 传它,同一网络里任何人都能抓走,之后几天都能顶着我登录。为了省事把后台开在 http 上,是拿安全换懒,不划算。
而且我这个开关本来就写对了:
response_obj.set_cookie(
SESSION_COOKIE_NAME,
data["token"],
httponly=True,
secure=BLOG_ENV == "production",
samesite="lax",
max_age=60 * 60 * 24 * 30,
path="/",
)只有生产环境带 Secure,这个逻辑没有问题。出问题的是我访问它的方式。为了能用 http 登进去而把它改成 False,等于亲手废掉这个开关。
顺带想清楚的两个细节
第一个是 path。我设的是 /。站点跑在 /tiana 这个基础路径下,如果哪天想把 cookie 收窄到某个前缀,得确认前端所有请求路径都落在那个前缀里。否则你会撞上同一个坑的另一种形态:这次是路径太宽,下次是路径太窄,症状都是「会话莫名丢失」。
第二个是 SameSite。我用的是 lax。lax 下从外部链接点进来(顶层导航)会带上 cookie,站内的 POST 请求也会带。如果改成 strict,从别的站点点进来的第一个请求就会丢掉会话,看上去又像掉线。对个人站点的后台来说,lax 是合适的档位。
收尾
三句话记住这个坑:
- 后台用 http 访问,带 Secure 的会话 cookie 不会回传,而且全程没有任何报错
- 本地 curl 因为有 loopback 例外,测不出这个问题,别把它当作验证依据
- 生产的正解是让后台走 https,不是把 Secure 关掉
如果确实处在一个没有证书、又必须进后台的阶段,我的做法是在服务器上开一个 ssh 端口转发,从 localhost 走,而不是去动 Secure。这个下次单独写。