
上游回两个 Cookie,网关转出去以后浏览器只认出来一个,另一个像是没设过。这种情况多半是把 Set-Cookie 的值拼成了一行。
规范先说清楚了不许合并
HTTP 里同名的头字段一般可以合并成一行,用逗号分隔。Set-Cookie 是明文规定的例外。RFC 7230 在讲字段合并的那一节留了这段说明:
Note: In practice, the "Set-Cookie" header field ([RFC6265]) often
appears multiple times in a response message and does not use the
list syntax, violating the above requirements on multiple header
fields with the same name. Since it cannot be combined into a
single field-value, recipients ought to handle "Set-Cookie" as a
special case while processing header fields.
理由是下一步才看清的:Set-Cookie 的值里本身就带逗号。RFC 6265 给出的语法里,过期时间这个属性的值是一个 HTTP 日期:
expires-av = "Expires=" sane-cookie-date
sane-cookie-date = <rfc1123-date, defined in [RFC2616], Section 3.3.1>
RFC 1123 格式的日期长这样:Wed, 21 Oct 2026 07:28:00 GMT。星期几后面那个逗号就是致命的。用逗号当分隔符去连接两条 Set-Cookie,任何按逗号切分的解析器都会把一条 cookie 切成两条,切出来的第二半还长得像一条合法的 cookie。
批注:拼成一行的报文长什么样
本机起了一个上游和一个网关,上游一次回两个 Set-Cookie,其中一个带 Expires。网关有两个分支,一个把数组拼成一行,一个原样给数组。下面是客户端收到的原始字节。
上游原始回来的 Set-Cookie 数组:
sid=abc123; Path=/; HttpOnly; SameSite=Lax
theme=dark; Path=/; Expires=Wed, 21 Oct 2026 07:28:00 GMT
=== 网关 /naive 的响应头(原始字节)===
Set-Cookie: sid=abc123; Path=/; HttpOnly; SameSite=Lax, theme=dark; Path=/; Expires=Wed, 21 Oct 2026 07:28:00 GMT
-- Set-Cookie 行数:1
-- 客户端 getSetCookie() 拿到 1 条:
sid=abc123; Path=/; HttpOnly; SameSite=Lax, theme=dark; Path=/; Expires=Wed, 21 Oct 2026 07:28:00 GMT
-- headers.get('set-cookie'):"sid=abc123; Path=/; HttpOnly; SameSite=Lax, theme=dark; Path=/; Expires=Wed, 21 Oct 2026 07:28:00 GMT"
那两行中间只差一个逗号加空格,一个还能用,一个已经坏了。
批注:坏在哪一步
把朴素分支发出的那一行按逗号切开,再按分号取每段的第一个键值对,得到的是三条:
=== 朴素写法真正发出的那一行 ===
sid=abc123; Path=/; HttpOnly; SameSite=Lax, theme=dark; Path=/; Expires=Wed, 21 Oct 2026 07:28:00 GMT
按逗号切开(很多粗暴解析器就是这么干的):
[0] "sid=abc123; Path=/; HttpOnly; SameSite=Lax"
[1] "theme=dark; Path=/; Expires=Wed"
[2] "21 Oct 2026 07:28:00 GMT"
再按 ; 取第一个 name=value:
"sid=abc123"
"theme=dark"
"21 Oct 2026 07:28:00 GMT"
theme 那条的 Expires 只剩 Wed,不是一个合法日期,过期属性被当作无效丢掉,于是这条 cookie 从"明年过期"变成"关掉浏览器就没"。第三条更荒唐,21 Oct 2026 07:28:00 GMT 会被当成一个新的 cookie 名塞进去。同一个响应在合规的解析器里可能报错丢掉整行,在宽松的解析器里就变成上面这种半坏状态,具体表现随客户端而变。
批注:Node 的 setHeader 本来就收数组
上游响应在 Node 里读出来的时候,set-cookie 就是数组,不需要手动拼。转发时把这个数组原样交给 setHeader,每个元素发一行:
const upstream = await fetch(upstreamUrl);
const bff = http.createServer((req, res) => {
res.statusCode = upstream.status;
// 数组原样传,Node 会为每个元素写一行 Set-Cookie
res.setHeader("Set-Cookie", upstream.headers.getSetCookie());
upstream.body.pipe(res);
});
如果一定要经过一层自己的处理,那就别用字符串。res.setHeader("Set-Cookie", list.join(", ")) 与 res.setHeader("Set-Cookie", list) 在代码里只差几个字符,发出的报文一个是一行、一个是两行。实测的数组分支:
=== 网关 /array 的响应头(原始字节)===
Set-Cookie: sid=abc123; Path=/; HttpOnly; SameSite=Lax
Set-Cookie: theme=dark; Path=/; Expires=Wed, 21 Oct 2026 07:28:00 GMT
-- Set-Cookie 行数:2
-- 客户端 getSetCookie() 拿到 2 条:
sid=abc123; Path=/; HttpOnly; SameSite=Lax
theme=dark; Path=/; Expires=Wed, 21 Oct 2026 07:28:00 GMT
-- headers.get('set-cookie'):"sid=abc123; Path=/; HttpOnly; SameSite=Lax, theme=dark; Path=/; Expires=Wed, 21 Oct 2026 07:28:00 GMT"
最后一行值得单独看一眼:这条正确响应里,headers.get('set-cookie') 返回的仍然是一根拼好的字符串,和坏掉的那个分支完全相同。两条分支在这一行上无法区分,因为 get 会把多个同名头按逗号拼起来返回。用它来验证这类改动,等于拿一把量不出差别的尺子。
批注:客户端用 getSetCookie
Node 18.14 之后客户端侧有了 res.headers.getSetCookie(),拿到的是数组,长度对应实际行数。上面的实测里,两个分支的 getSetCookie() 一个返回 2 条,一个返回 1 条,差别就出来了。写代理、网关、mock 服务的时候用这个方法断言,才不会漏掉合并。
游戏规则在两个方向上不一样。请求方向的 Cookie 头本来就可以合并,浏览器发出去的 Cookie: a=1; b=2 就是多条用分号连成一条,接收端再把分号切开。响应方向的 Set-Cookie 反过来,必须一行一条。同一个字段名,两个方向的合并规则相反,这也是它容易被写错的原因。
结论
网关里跟 Cookie 有关的值一律按列表处理,拼接和排序都不要做。这类地方我倾向在评审时专门看一眼 setHeader 的第二个参数是不是数组。
排查同类问题时的顺序:先看原始字节里的 Set-Cookie 行数,再用 getSetCookie() 的长度确认,最后才去看 cookie 属性和值。用 headers.get('set-cookie') 做中间那一步,会把两个分支看成一回事。