本地登录正常,公网却掉线?Express Session 经 cpolar HTTPS 访问的 Cookie 排障实战
本地登录正常,公网登录返回 200,刷新却变成 401?别急着换认证方案,也别删除 secure: true。先查三件事:服务器发没发 Cookie,浏览器收没收,下次请求带没带。
这篇只用虚构测试账号。cpolar 提供本地 3000 端口的公网 HTTPS 入口,Express 管协议识别、会话保存与撤销。页面和 API 同源,不引入 CORS、iframe 或第三方登录。

图1|原理示意:浏览器HTTPS经cpolar到本地HTTP;登录200不等于Cookie已保存,非实测截图。
两段连接各有协议判断。
1 先认清 Cookie 在这条链路里负责什么
1.1 只测同源页面,不混进跨站登录
链路可以写成下面这样:
text
浏览器 -- HTTPS --> cpolar 公网入口
|
隧道连接
|
本机 cpolar
|
HTTP 请求
v
127.0.0.1:3000
1.2 分清两个"安全"判断
浏览器依据自己访问的 URL 与 Cookie 属性决定是否收发。Express 则依据本地连接、受信代理传来的协议信息计算 req.protocol 与 req.secure。
下面是本次故障组的关键配置,不是推荐的上线配置:
javascript
app.set('trust proxy', false);
// 会话配置里仍然使用:
const cookie = { secure: true };
当本地连接是 HTTP 且代理不受信时,req.secure 为 false。此时 express-session 不会把 Secure 会话 Cookie 写入响应;登录逻辑自身返回 200,并不能证明浏览器已经获得登录凭据。
1.3 先约定实验边界
代码在 Node.js v26.8.1、Express 5.1.0、express-session 1.18.2 上通过本地 HTTP 自动化测试,覆盖 Cookie 属性、代理信任、CSRF、轮换、退出重放与过期。公网隧道和双设备部分是现场验收步骤,不冒充已完成的公网测量。协议头覆盖行为必须按第 5 节核查,通过后才提交密码。
2 准备环境:只建一个临时测试应用
2.1 建目录、固定依赖版本
以下使用 macOS/Linux Bash,Windows 可用 WSL。固定实验依赖,保留锁文件;复现时使用 npm ci。
bash
node --version
npm --version
curl --version
python3 --version
bash
mkdir express-cookie-lab
cd express-cookie-lab
npm init -y
npm install --save-exact express@5.1.0 express-session@1.18.2
mkdir public
bash
npm ls express express-session
bash
npm ci
2.2 生成仅供本次实验使用的凭据
在终端 A 设置随机会话签名密钥和测试密码。密码不是你的工作账号密码,也不要把现有项目的密钥复制进来。
bash
export SESSION_SECRET="$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('hex'))")"
export DEMO_PASSWORD="$(node -e "process.stdout.write(require('node:crypto').randomBytes(24).toString('hex'))")"
export TTL_MS=300000
bash
node -e "console.log({secretLength:process.env.SESSION_SECRET.length,passwordLength:process.env.DEMO_PASSWORD.length})"
bash
umask 077
node -e "require('node:fs').writeFileSync('login.json',JSON.stringify({username:'demo',password:process.env.DEMO_PASSWORD}))"
不要创建真实用户表。这个应用没有注册、找回密码和生产级登录限流,只适合授权人员短时测试;公网入口不是长期服务地址。
3 写好应用:登录、查看、修改和退出都要能验
3.1 保存完整服务端代码
把下面内容保存为 server.cjs。.cjs 明确使用 CommonJS,避免项目默认模块类型造成导入错误。应用只监听回环地址,公网只走本机隧道。
javascript
const express = require('express');
const session = require('express-session');
const crypto = require('node:crypto');
const app = express();
const mode = process.env.MODE || 'local';
if (!['local', 'broken', 'secure'].includes(mode)) throw Error('bad MODE');
const origin = new URL(process.env.PUBLIC_ORIGIN || 'http://127.0.0.1:3000').origin;
const secret = process.env.SESSION_SECRET;
const password = process.env.DEMO_PASSWORD;
if (!secret || secret.length < 64 || !password || password.length < 20) {
throw Error('set strong SESSION_SECRET and DEMO_PASSWORD');
}
const ttl = Number(process.env.TTL_MS || 300000);
if (!Number.isInteger(ttl) || ttl < 1000) throw Error('bad TTL_MS');
if (mode !== 'local' && !origin.startsWith('https://')) throw Error('HTTPS origin required');
app.disable('x-powered-by');
if (mode === 'secure') {
app.set('trust proxy', ip => ip === '127.0.0.1' || ip === '::ffff:127.0.0.1');
}
app.use((req, res, next) => {
res.set('Cache-Control', 'no-store');
res.set('X-Content-Type-Options', 'nosniff');
res.set('Referrer-Policy', 'no-referrer');
next();
});
app.get('/health', (req, res) => res.json({ ok: true }));
app.get('/diag', (req, res) => {
res.json({
remoteAddress: req.socket.remoteAddress,
encrypted: Boolean(req.socket.encrypted),
forwardedProto: req.get('x-forwarded-proto') || null,
protocol: req.protocol,
secure: req.secure
});
});
app.use(express.json({ limit: '2kb' }));
const cookieOptions = { path: '/', httpOnly: true, sameSite: 'lax', secure: mode !== 'local' };
app.use(session({
name: 'lab.sid', secret, resave: false, saveUninitialized: false,
cookie: { ...cookieOptions, maxAge: ttl }
}));
function equal(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
const x = Buffer.from(a), y = Buffer.from(b);
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
function sameOrigin(req, res, next) {
if (req.get('origin') !== origin) return res.status(403).json({ error: 'origin' });
next();
}
function auth(req, res, next) {
if (!req.session.user) return res.status(401).json({ error: 'login_required' });
if (Date.now() >= req.session.deadline) {
return req.session.destroy(err => {
if (err) return next(err);
res.clearCookie('lab.sid', cookieOptions);
res.status(401).json({ error: 'expired' });
});
}
next();
}
function csrf(req, res, next) {
if (!equal(req.get('x-csrf-token'), req.session.csrf)) {
return res.status(403).json({ error: 'csrf' });
}
next();
}
app.post('/login', sameOrigin, (req, res, next) => {
if (req.body?.username !== 'demo' || !equal(req.body?.password, password)) {
return res.status(401).json({ error: 'credentials' });
}
req.session.regenerate(err => {
if (err) return next(err);
req.session.user = 'demo';
req.session.csrf = crypto.randomBytes(32).toString('hex');
req.session.deadline = Date.now() + ttl;
req.session.save(err => {
if (err) return next(err);
res.json({ user: 'demo', csrf: req.session.csrf });
});
});
});
app.get('/me', auth, (req, res) => res.json({
user: req.session.user, deadline: req.session.deadline
}));
app.get('/csrf', auth, (req, res) => res.json({ csrf: req.session.csrf }));
app.post('/change', sameOrigin, auth, csrf, (req, res, next) => {
req.session.changes = (req.session.changes || 0) + 1;
req.session.save(err => {
if (err) return next(err);
res.json({ changes: req.session.changes });
});
});
app.post('/logout', sameOrigin, auth, csrf, (req, res, next) => {
req.session.destroy(err => {
if (err) return next(err);
res.clearCookie('lab.sid', cookieOptions);
res.sendStatus(204);
});
});
app.use(express.static('public', { etag: false, maxAge: 0 }));
app.use((err, req, res, next) => {
res.status(err.status === 400 ? 400 : 500).json({ error: 'request_failed' });
});
app.listen(3000, '127.0.0.1', () => console.log(`lab ready: ${mode}`));
3.2 解释几个不能省的动作
local 只供本机 HTTP 基线;broken 开启 Secure 却不信任代理;secure 限定回环来源。session 不设置 proxy: true,让它沿用 Express 的信任规则。
退出不是删一个前端变量:先 destroy() 删除服务端会话,成功后再 clearCookie()。清除时保留同样的名称、路径与属性,不把原来的 maxAge 传给清除操作。
3.3 准备同源测试页面
把下面文件保存为 public/index.html。页面不读取 HttpOnly Cookie;它只保存 CSRF token,并让浏览器按同源规则携带 Cookie。页面刷新后可点"恢复状态"重新获取当前会话的 CSRF token。
html
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Session Cookie 实验</title>
<h1>Session Cookie 实验</h1>
<p>仅使用本次生成的测试密码。</p>
<input id="password" type="password" autocomplete="off" placeholder="测试密码">
<button id="login">登录</button>
<button id="me">恢复状态</button>
<button id="change">修改计数</button>
<button id="logout">退出</button>
<pre id="out"></pre>
<script>
let csrf = '';
const out = document.querySelector('#out');
async function call(path, method = 'GET', body) {
const response = await fetch(path, {
method,
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
...(csrf ? { 'X-CSRF-Token': csrf } : {})
},
body: body ? JSON.stringify(body) : undefined
});
const data = response.status === 204 ? {} : await response.json();
if (data.csrf) csrf = data.csrf;
const { csrf: hidden, ...visible } = data;
out.textContent = JSON.stringify({ status: response.status, ...visible }, null, 2);
return response;
}
document.querySelector('#login').onclick = async () => {
const input = document.querySelector('#password');
await call('/login', 'POST', { username: 'demo', password: input.value });
input.value = '';
};
document.querySelector('#me').onclick = async () => {
const response = await call('/csrf');
if (response.ok) await call('/me');
};
document.querySelector('#change').onclick = () => call('/change', 'POST');
document.querySelector('#logout').onclick = async () => {
const response = await call('/logout', 'POST');
if (response.ok) csrf = '';
};
</script>
</html>
4 跑通本地基线:先证明业务逻辑没有坏
4.1 本机启动,不开隧道
在终端 A 启动本地组,保持进程前台运行。后续切换组别时,只对这个进程按 Ctrl+C,再输入新的启动命令,不去清理系统中其他 Node 服务。
bash
MODE=local PUBLIC_ORIGIN=http://127.0.0.1:3000 node server.cjs
bash
umask 077
export BASE=http://127.0.0.1:3000
curl --fail-with-body -sS "$BASE/health"
json
{"ok":true}
再确认匿名用户没有登录。这里只读状态码,不输出整套 HTTP 头,减少凭据被带进截图的机会。
bash
curl -sS -o /dev/null -w '%{http_code}\n' "$BASE/me"
4.2 用 Cookie jar 验证第二次请求
终端 B 进入同一目录执行 curl;匿名 /me 应为 401,带 Cookie 后应为 200。
bash
curl --fail-with-body -sS -c local.cookies -H "Origin: $BASE" -H 'Content-Type: application/json' --data-binary @login.json "$BASE/login" -o local-login.json
携带 Cookie 访问身份接口:
bash
curl --fail-with-body -sS -b local.cookies "$BASE/me"

图2|本地基线预期验收示意:登录后恢复状态应返回200与demo;仅展示Cookie属性,不展示凭据。
这里看第二次请求能否认出用户。
5 开启 cpolar:先测协议头,再提交密码
5.1 获取真实 HTTPS 地址
从 cpolar 官网下载页 按系统安装客户端,并完成自己的账号认证。本文不替换现有配置文件,不重启已经运行的后台隧道,也不要求在文章里粘贴 authtoken。
bash
cpolar version
在独立终端 C 启动临时 HTTP 隧道,映射的仍是本地 HTTP 3000 端口;命令中的 http 不是要求浏览器使用明文访问。
bash
cpolar http 3000
保留前台进程。从这次运行展示的转发地址里复制 HTTPS 地址,不要套用别人的域名,也不要拿 HTTP 地址代替。若已有实例限制提示,先核对现有运行方式,不要反复新开客户端。
bash
printf '粘贴本次 HTTPS 地址:'
read -r BASE
export BASE
BASE="$(python3 - <<'PY'
import os
from urllib.parse import urlsplit
u = urlsplit(os.environ['BASE'].strip())
assert u.scheme == 'https' and u.hostname
assert not u.username and not u.password
assert u.path in ('', '/') and not u.query and not u.fragment
print('https://' + u.netloc)
PY
)"
export BASE
printf '%s\n' "$BASE"
bash
curl --fail-with-body -sS "$BASE/health"
curl --fail-with-body -sS "$BASE/diag"
5.2 看四个字段,不猜代理层数
当前还是 local 组,因此没有开启代理信任。记录 remoteAddress、encrypted、forwardedProto 和 secure:本地回环直连应符合代码绑定方式,HTTP 回源的 encrypted 为 false。
公网 HTTPS 验收要求应用收到的 forwardedProto 为单值 https。如果为空、值不符,或来源并非此文约定的回环连接,停下来核查实际拓扑,不要加 trust proxy: true 把异常压下去。
还要检查公网用户能否污染该头。下面两次请求不携带任何凭据,只比较诊断响应:
bash
curl --fail-with-body -sS -H 'X-Forwarded-Proto: http' "$BASE/diag"
curl --fail-with-body -sS -H 'X-Forwarded-Proto: https, http' "$BASE/diag"
两次结果里的 forwardedProto 都应保持由 HTTPS 入口确定的单值 https。如果用户输入穿透到应用,或被追加成多值,就不能把这个头直接当安全依据;应先修正受控代理的覆盖规则,再继续本实验。这里不提供一条"把所有请求强行改成 https"的快捷修复。

图3|隧道与诊断原理示意:四个字段须按现场响应验收;公网隧道尚未实测。
公网 HTTPS 与 HTTP 回源并不矛盾。
6 复现故障,再只修代理信任这一项
6.1 故障组:登录 200,但没有 Set-Cookie
停止终端 A 的应用,在该终端交互输入同一个 HTTPS 地址,去掉末尾斜杠后启动故障组。这里的 origin 必须与终端 B 的 BASE 一致。
bash
printf '粘贴同一个 HTTPS 地址,不带末尾斜杠:'
read -r PUBLIC_ORIGIN
export PUBLIC_ORIGIN
MODE=broken node server.cjs
终端 B 提交登录,响应头仅保存在本地文件,不直接打印。这个阶段清空旧 jar 的办法是换一个新文件名,而不是删除浏览器所有站点的数据。
bash
curl -sS -D broken.headers -c broken.cookies -H "Origin: $BASE" -H 'Content-Type: application/json' --data-binary @login.json "$BASE/login" -o broken-login.json
bash
python3 - <<'PY'
from pathlib import Path
lines = Path('broken.headers').read_text().splitlines()
print('session_cookie_headers =', sum(
x.lower().startswith('set-cookie: lab.sid=') for x in lines
))
PY
在本节约定的 HTTP 回源、不信任代理条件下,计数为 0。继续请求 /me 应为 401:
bash
curl -sS -b broken.cookies -o /dev/null -w '%{http_code}\n' "$BASE/me"
6.2 修复组:保留 Secure,限定信任来源
只有第 5 节的来源与转发头验收通过,才进入这一组。在终端 A 停止故障组,使用原密钥、原 origin 启动:
bash
MODE=secure node server.cjs
bash
curl --fail-with-body -sS "$BASE/diag"
bash
curl --fail-with-body -sS -D secure.headers -c secure.cookies -H "Origin: $BASE" -H 'Content-Type: application/json' --data-binary @login.json "$BASE/login" -o secure-login.json
此时 /diag 应显示 protocol: https、secure: true、encrypted: false。下面只列 Cookie 属性,隐藏值;不设置 Domain,Cookie 仅属于当前主机。
bash
python3 - <<'PY'
from pathlib import Path
items = [x for x in Path('secure.headers').read_text().splitlines()
if x.lower().startswith('set-cookie: lab.sid=')]
assert len(items) == 1
attrs = [x.strip() for x in items[0].split(';')[1:]]
low = [x.lower() for x in attrs]
assert 'secure' in low and 'httponly' in low
assert 'samesite=lax' in low and 'path=/' in low
assert not any(x.startswith('domain=') for x in low)
print('lab.sid=REDACTED; ' + '; '.join(attrs))
PY
bash
curl --fail-with-body -sS -b secure.cookies "$BASE/me"
应返回 demo。别通过关闭 Secure 获得这个结果,否则你只是改变了题目,没有修好代理协议识别。

图4|故障与修复预期对照:仅在来源及转发头核查通过后信任代理,保留Secure;非公网实测截图。
比较前后 Cookie 是否发出。
7 验收安全边界:能登录还不够
7.1 缺少 CSRF token 的写请求必须失败
提取 token 到变量,别输出它。SameSite 与 HttpOnly 都不能替代 CSRF 校验。
bash
CSRF="$(python3 -c 'import json; print(json.load(open("secure-login.json"))["csrf"])')"
不带 token 发起修改,即使已经携带 Cookie,也应该返回 403。
bash
curl -sS -b secure.cookies -X POST -H "Origin: $BASE" -o /dev/null -w '%{http_code}\n' "$BASE/change"
补上本会话的 token 再请求。这个接口只修改测试计数,没有真实业务副作用。
bash
curl --fail-with-body -sS -b secure.cookies -c secure.cookies -X POST -H "Origin: $BASE" -H "X-CSRF-Token: $CSRF" "$BASE/change"
7.2 登录轮换:旧 Cookie 不能继承新身份
备份 jar 后重新登录,regenerate() 应使旧 jar 返回 401,新 jar 返回 200。
bash
cp secure.cookies before-login.cookies
curl --fail-with-body -sS -b secure.cookies -c secure.cookies -H "Origin: $BASE" -H 'Content-Type: application/json' --data-binary @login.json "$BASE/login" -o secure-login.json
bash
curl -sS -b before-login.cookies -o /dev/null -w '%{http_code}\n' "$BASE/me"
curl -sS -b secure.cookies -o /dev/null -w '%{http_code}\n' "$BASE/me"
7.3 退出后重放:证明服务端真的撤销了会话
轮换后重新提取 token。退出应为 204,拿退出前的 jar 重放应为 401;只删浏览器 Cookie 不算撤销。
bash
CSRF="$(python3 -c 'import json; print(json.load(open("secure-login.json"))["csrf"])')"
cp secure.cookies before-logout.cookies
curl -sS -b secure.cookies -c secure.cookies -X POST -H "Origin: $BASE" -H "X-CSRF-Token: $CSRF" -o /dev/null -w '%{http_code}\n' "$BASE/logout"
bash
curl -sS -b before-logout.cookies -o /dev/null -w '%{http_code}\n' "$BASE/me"
7.4 过期要查服务端,不只等浏览器删 Cookie
终端 A 停止当前进程后启动五秒组。终端 B 重新登录,五秒内 /me 应为 200;下面强制发送旧 Cookie,验证服务端拒绝而非 jar 自动过期。
bash
TTL_MS=5000 MODE=secure node server.cjs
bash
curl --fail-with-body -sS -c expiry.cookies -H "Origin: $BASE" -H 'Content-Type: application/json' --data-binary @login.json "$BASE/login" -o expiry-login.json
curl -sS -b expiry.cookies -o /dev/null -w '%{http_code}\n' "$BASE/me"
bash
python3 - <<'PY'
from pathlib import Path
for line in Path('expiry.cookies').read_text().splitlines():
if not line or (line.startswith('#') and not line.startswith('#HttpOnly_')):
continue
fields = line.split(' ')
if len(fields) == 7 and fields[5] == 'lab.sid':
Path('replay.headers').write_text('Cookie: lab.sid=' + fields[6] + '\n')
break
else:
raise SystemExit('session cookie absent')
PY
sleep 6
curl -sS -H @replay.headers -o /dev/null -w '%{http_code}\n' "$BASE/me"
期望 401。服务端会话存储中的过期检查与显式 deadline 都属于服务器侧约束,浏览器是否主动移除 Cookie 不再影响本次判断。完成后恢复五分钟组,再进行人工浏览器测试。
8 两台设备再走一遍:区分客户端与服务端故障
8.1 桌面与手机各自登录
终端 A 停止短时组,恢复正常实验时限。用当前 HTTPS 地址打开页面,桌面与手机各自输入这次生成的测试密码;不要拷贝电脑的 Cookie 到手机。
bash
TTL_MS=300000 MODE=secure node server.cjs
手机切到移动网络,密码仅从本机受限文件取用,不发公共群聊。每台设备按"登录 → 恢复状态 → 刷新 → 恢复状态 → 修改计数 → 退出 → 恢复状态"走一遍。最后一次恢复状态应为 401。一台设备退出后另一台仍保持登录,是本例每个浏览器会话独立的设计,不是退出失效。
桌面浏览器在开发者工具里查看 Network 中 /login 的响应头,再看 Application/Storage 对应站点的 Cookie 表。HttpOnly Cookie 不出现在 document.cookie 里,不能据此认定它没有保存。

图5|双设备预期验收示意:会话相互独立,退出及旧Cookie重放应返回401;双设备尚未实测。
这里只撤销当前设备会话,不是全设备退出。
8.2 按证据选择排错入口
| 观察结果 | 先检查什么 | 本文的处理边界 |
|---|---|---|
| 登录 200,响应没有会话 Set-Cookie | req.secure、Secure 设置、session 是否被修改 | 修代理信任,不关闭公网 Secure |
| 有 Set-Cookie,浏览器不保存 | 开发者工具阻止原因、Domain、SameSite、实际协议 | 当前同源 HTTPS,不设 Domain |
| 已保存但请求不携带 | 请求的主机、路径、协议、过期时间、credentials | 保持同一个 origin,使用同源 fetch |
| 请求带 Cookie 仍然 401 | 会话是否撤销、过期、重启、签名密钥与存储 | 核对服务端状态,不反复换浏览器 |
8.3 域名变化后重新登录,不扩大 Domain
换地址后同时更新 BASE、PUBLIC_ORIGIN 并重新登录。同一主机的不同端口不隔离 Cookie,但属于不同 origin;localhost 与 127.0.0.1 则是不同主机。
host-only Cookie 只属于设置它的主机。换成另一个公网主机名后,浏览器不会沿用前一个主机的 Cookie;修改端口不是同一回事,别混淆主机和 origin。
9 收尾:关掉实验入口,再谈生产化
9.1 清理只属于这次实验的资料
在终端 C 对本次前台隧道按 Ctrl+C,在终端 A 对本次 Node 进程按 Ctrl+C。不要用按进程名批量杀死的命令,免得影响同机其他隧道或开发服务。
bash
ls -l login.json *.cookies *-login.json *.headers
bash
rm -f login.json local.cookies local-login.json broken.headers broken.cookies broken-login.json secure.headers secure.cookies secure-login.json before-login.cookies before-logout.cookies expiry.cookies expiry-login.json replay.headers
unset CSRF BASE
bash
unset SESSION_SECRET DEMO_PASSWORD PUBLIC_ORIGIN TTL_MS
9.2 MemoryStore 到这里为止
MemoryStore 只供单进程实验,重启丢失会话。生产使用共享存储并核查 TTL、撤销与并发语义。代码显式检查绝对 deadline,不把 Cookie 有效期当成唯一约束。
别扩大 Domain 到共享后缀,也别把 Cookie 拼进 URL。容器与多层代理须重新核查信任来源。正式部署移除或限制 /diag,补齐限流、审计、账号管理和安全响应头。生产密钥应安全持久配置,不能每次启动随机重建。curl 不执行浏览器全部 Cookie 策略,双设备验证不能省略。
10 总结:沿着 Cookie 走一圈,比盲改配置快
验收完成后,本地应用、cpolar HTTPS 入口、受限代理信任和浏览器 Cookie 连成一条可解释的链路。登录只是起点,服务端轮换、过期与撤销同样有独立证据。
- 看不到 Cookie 时,先分清"没发出""没保存""没带回",不要直接关闭 Secure。
- 配
trust proxy前,核对直连来源与转发头覆盖行为;回环绑定不等于自动拥有可信代理。 - 登录成功后继续验收 CSRF、旧 Cookie 重放和过期,退出按钮不是服务端撤销的证据。
参考资料:express-session 官方文档、Express 反向代理指南、MDN Set-Cookie、cpolar 文档。