跨子域会话共享 (Cross-Subdomain Session Sharing)
1. 一句话
要让「一次登录、同注册域下所有子域通用」,靠的是 Set-Cookie 的 Domain 属性。不写它,cookie 严格绑定当前主机——a.example.com 登过之后,b.example.com 还得再登一次。这不是浏览器的毛病,是作用域规则。
2. 症状
- 每个子域都要单独登录一次,明明背后是同一套凭证、同一个鉴权服务。
- 手机上的「免密码 / 记住登录」也不管用——密码管理器按 origin 存储,在
note.example.com存的凭据不会被用到map.example.com。 - 鉴权服务本身完全正常:签名密钥、会话格式、校验逻辑全对。只是浏览器不肯把 cookie 发过去。
3. 根因
Set-Cookie 不写 Domain 时,作用域是 host-only:只有主机名完全一致才会被发送。
Set-Cookie: admin_session=...; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400
↑ 没有 Domain = 只管当前这一个主机写上 Domain 后,作用域变成该域及其所有子域:
Set-Cookie: admin_session=...; Path=/; Domain=.example.com; HttpOnly; ...Domain 取值 | 生效范围 |
|---|---|
| 不写 | 仅当前主机(host-only) |
Domain=example.com | 该域及其所有子域 |
Domain=.example.com | 同上(前导点是历史写法,现代浏览器等价) |
Domain=a.example.com | a.example.com 及其子域,不含 example.com 本身 |
两条硬约束:
- 不能跨注册域。
example.com下发的 cookie,example.net收不到。同一份代码要服务多个注册域时,必须按请求 Host 动态推导,写死会出现"串域"或"全部失效"。 - 不能是 IP 或
localhost。 浏览器会直接拒绝带Domain的 cookie。所以本地/内网调试路径必须有兜底,否则改造完本地就跑不起来。
4. 解法:按 Host 推导注册域
function domainAttr(req) {
const host = String(req.headers.host || "").split(":")[0].toLowerCase();
if (!host || host === "localhost" || /^[\d.]+$/.test(host)) return "";
const parts = host.split(".");
if (parts.length < 2) return "";
return `; Domain=.${parts.slice(-2).join(".")}`;
}三条兜底缺一不可:localhost 不下发、IP(^[\d.]+$)不下发、单段主机名不下发。返回空串时保持原有 host-only 行为,等于安全降级。
⚠️ 取「末两段」只适用于.com/.chat/.net这类单段后缀。遇到.co.uk、.com.cn这类多段公共后缀会算错(算出.co.uk),必须改用公共后缀表(PSL)。
前提:反向代理要把原始 Host 透传给后端(nginx 的 proxy_set_header Host $host;),否则后端只看得到 127.0.0.1:3021,推导必然失败。
5. 必须一并处理:退出登录要清两种 cookie
改完 Domain 之后,存量用户身上还留着历史 host-only 的 cookie。于是:
- 退出登录只清父域那份 → 旧 cookie 还在,页面看起来没登出。
- 浏览器发送 cookie 的顺序大致按创建时间早的在前,服务端若取第一个匹配项,还可能读到旧票据。
所以清除时必须同时下发两条:
const base = `${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT`;
res.setHeader("Set-Cookie", [base, base + domainAttr(req)]); // host-only + 父域新系统没这个包袱,可以只发一条;在有历史 cookie 的存量系统上,这条不能省。
6. 安全权衡(要主动认下的代价)
父域 cookie 意味着 *.example.com 上任何一个子域,都能让浏览器带上这份会话。
HttpOnly只防 JS 读,防不住浏览器自动携带。SameSite=Lax只隔离跨站,同站子域完全不在管辖范围内。
即:只要有一个子域被注入,或加载了不该加载的第三方脚本,它就能以你的身份向其他子域发起已认证请求。
详见 用户生成内容同源托管导致凭证窃取 (UGC Same-Origin Hosting & Credential Theft)——那篇讲的是同一枚硬币的反面(用户内容 + 共享 cookie = 账号接管)。
判断标准:所有子域都是自己写的自用服务 → 收益远大于风险,值得做;只要有一个子域对外提供内容、或会加载第三方脚本 → 改用第 7 节的方案。
另:设了 Domain 就失去 __Host- 前缀的加固资格(该前缀强制要求无 Domain 属性)。
7. 替代方案
父域做 IdP,子域各持本地会话(SSO 握手)
不共享 cookie,保留隔离性:
- 子域未登录 → 302 到
https://example.com/api/login?next=<原URL> - 父域验自己的 cookie → 带一次性 token 回跳子域
- 子域用该 token 换取自己的 host-only cookie
代价是多一套握手实现。适合"部分子域不完全可信"的场景。
WebAuthn / Passkey:RP ID 设在父域
Passkey 的 RP ID 作用域规则和 cookie 的 Domain 是同一个道理:RP ID 设为 example.com,a.example.com、b.example.com 就共用同一把私钥。
手机上"免密码对子域不友好",根因往往就是注册时 RP ID 被设成了具体子域而不是父域。
浏览器支持(2026 年现状):Safari(iOS 16 / macOS 13 起)、Chrome、Edge、Firefox 122(2024-02)起均已支持,不是 Chrome 独有。
注意:passkey 只解决「怎么证明你是谁」,维持会话仍然要靠第 4 节的 cookie。两者是叠加关系,不是替代关系。
不要用 IP 白名单兜底
出口 IP 一多或会变,维护成本急剧上升,换个网络就失效。除非是固定的服务器间调用,否则不划算。
8. 下次怎么识别
- 「每个子域都要重登一次」→ 先看
Set-Cookie有没有Domain,不要先怀疑鉴权服务。 - 手机/密码管理器的免登录在子域间不生效 → 同上,是 cookie 作用域,不是手机的问题。
- 改完
Domain后「退出登录没效果」→ 十有八九是没清 host-only 那条历史 cookie。 - 一份代码服务多个注册域 → 检查
Domain是否按 Host 动态推导。 - 改了之后本地跑不起来 → 多半是
localhost/ IP 被浏览器拒了,检查兜底分支。
9. 相关
- Web 登录鉴权 (Session, Cookie, Bearer, JWT) —— 讲 cookie 的过期(第 5 节);这篇补的是作用域。
- 用户生成内容同源托管导致凭证窃取 (UGC Same-Origin Hosting & Credential Theft) —— 父域 cookie 的攻击面视角。
- CORS 带凭据与通配符互斥 (CORS Credentials vs Wildcard) —— 同为"浏览器侧凭证策略"。
来源:2026-09-16 改造自建服务auth-api(6767.chat下 20+ 子域)。原实现"有意不设Domain",导致每个子域独立登录;改为按 Host 推导注册域后,一次登录覆盖全部子域,会话有效期同时由 7 天放宽到 180 天。
本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/Web