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.coma.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,推导必然失败。


改完 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,保留隔离性:

  1. 子域未登录 → 302 到 https://example.com/api/login?next=<原URL>
  2. 父域验自己的 cookie → 带一次性 token 回跳子域
  3. 子域用该 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. 相关

来源:2026-09-16 改造自建服务 auth-api(6767.chat 下 20+ 子域)。原实现"有意不设 Domain",导致每个子域独立登录;改为按 Host 推导注册域后,一次登录覆盖全部子域,会话有效期同时由 7 天放宽到 180 天。

本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/Web