Object.fromEntries(headers) silently drops your session cookie
published
TL;DR
Object.fromEntries(response.headers) and new Map(response.headers) both keep only the last Set-Cookie header, because a plain object cannot hold a duplicate key. headers.get('set-cookie') does not save you either — it returns every cookie joined with ", ", and Expires=Wed, 21 Oct 2026 07:28:00 GMT already contains a comma, so splitting on , shreds the value. Use headers.getSetCookie(), which returns an array.
The problem
A proxy route, a fetch wrapper, or a logger that copies upstream headers. It looks correct and it is not:
const upstream = await fetch(target)
const headers = Object.fromEntries(upstream.headers) // <- the bug
return new Response(body, { headers })
The upstream sent two cookies. Executed against a local node:http server on Node v26.2.0:
Object.fromEntries: {"connection":"keep-alive","content-length":"2",
"date":"Tue, 22 Sep 2026 16:56:11 GMT","keep-alive":"timeout=5",
"set-cookie":"theme=dark; Path=/; Max-Age=31536000","x-trace":"one"}
sid=abc is gone. theme=dark survived. Nothing threw, nothing warned, and the response still has a set-cookie header, so a quick eyeball of the output says it worked. new Map(upstream.headers) does exactly the same thing:
new Map(upstream.headers).get("set-cookie")
-> "theme=dark; Path=/; Max-Age=31536000"
In production this reads as “users get logged out at random” — random because it depends on the order the upstream emits its cookies, and the session cookie is usually not the last one.
Why it happens
A header list is a multimap: ordered key-value pairs with duplicate keys allowed. Object.fromEntries and new Map are last-write-wins on a duplicate key. The Headers iterator faithfully yields set-cookie twice — counting the set-cookie entries in a spread of the headers returns 2 — and the collector throws the first one away.
Every other header is safe from this, and that is exactly why the bug is rare enough to be surprising. The Fetch Standard says:
A header list is essentially a specialized multimap: an ordered list of key-value pairs with potentially duplicate keys. Since headers other than
Set-Cookieare always combined when exposed to client-side JavaScript, implementations could choose a more efficient representation, as long as they also support an associated data structure forSet-Cookieheaders.
So two x-foo headers become one entry with the value "a, b" — one key, nothing lost. Two set-cookie headers stay two entries, and a plain object can only hold one of them.
The obvious repair — read it with get() first — trades a silent drop for a silent corruption:
get(): "sid=abc; Path=/; Expires=Wed, 21 Oct 2026 07:28:00 GMT; HttpOnly, theme=dark; Path=/; Max-Age=31536000"
That is one string containing two cookies. Splitting it on , gives three pieces, because the Expires date has a comma in the middle of it:
split(",") -> ["sid=abc; Path=/; Expires=Wed", " 21 Oct 2026 07:28:00 GMT; HttpOnly", " theme=dark; Path=/; Max-Age=31536000"]
RFC 6265 anticipated this and forbade the folding outright:
Origin servers SHOULD NOT fold multiple Set-Cookie header fields into a single header field. The usual mechanism for folding HTTP headers fields (i.e., as defined in [RFC2616]) might change the semantics of the Set-Cookie header field because the %x2C (”,”) character is used by Set-Cookie in a way that conflicts with such folding.
The Expires attribute is an rfc1123-date, whose weekday is followed by a comma by definition. Any cookie with an expiry — which is most persistent cookies — is unsplittable once folded.
Here is what each access path hands you for the same two-cookie response:
| Access path | Result | Cookies preserved |
|---|---|---|
headers.getSetCookie() | array of 2 strings | 2 of 2 |
headers.get('set-cookie') | one comma-joined string | 2, but not separable |
Object.fromEntries(headers) or new Map(headers) | one string, last value only | 1 of 2 |
spread of headers into an entry array | two set-cookie entries | 2 of 2 |
Node res.headers['set-cookie'] from node:http | array of 2 strings | 2 of 2 |
The last row is the reason this trips people who have written Node for years. The old node:http client has always exposed set-cookie as an array — Array.isArray(res.headers['set-cookie']) is true — so the mental model “set-cookie is special and Node handles it” is correct right up until you switch to fetch, where the same data arrives through a Headers object with get() semantics.
What to do
Read cookies with getSetCookie(). It returns an array, and an empty array when there are none, so it needs no null check:
for (const cookie of response.headers.getSetCookie()) {
console.log(cookie)
}
Forward headers by copying everything except set-cookie, then appending the cookies back:
function forwardHeaders(upstream) {
const out = new Headers()
for (const [key, value] of upstream) {
if (key === 'set-cookie') continue
out.set(key, value)
}
for (const cookie of upstream.getSetCookie()) {
out.append('set-cookie', cookie)
}
return out
}
Run against the two-cookie response, the broken and fixed paths differ exactly as you would expect:
BROKEN forwarded: ["theme=dark; Path=/; Max-Age=31536000"]
FIXED forwarded: ["sid=abc; Path=/; Expires=Wed, 21 Oct 2026 07:28:00 GMT; HttpOnly","theme=dark; Path=/; Max-Age=31536000"]
If you only need to move the whole header list, pass the entry array, not an object. The Headers constructor accepts a sequence of pairs and keeps duplicates, so spreading the source headers into it preserves both cookies while Object.fromEntries preserves one.
Use append, never set, when writing more than one cookie. set replaces every existing value for the name:
const h = new Headers()
h.append('set-cookie', 'a=1')
h.append('set-cookie', 'b=2')
h.set('set-cookie', 'c=3')
h.getSetCookie() // ['c=3'] -- a and b are gone
A test that catches the regression needs two cookies and an assertion on the count, not on truthiness. Asserting that a set-cookie header merely exists passes on the broken code.
const res = await app.fetch(request)
assert.equal(res.headers.getSetCookie().length, 2)
Caveats
- Browsers cannot see
Set-Cookieat all. It is a forbidden response-header name, so in page JavaScriptgetSetCookie()on a fetched response returns an empty array regardless. MDN describes the method as intended for server environments. This bug lives in server-side and edge runtimes — Node, Deno, Bun, Workers — and in any code that constructs aHeadersobject by hand. - Joining cookies yourself does not round-trip. Passing the cookies pre-joined with
", "into theHeadersconstructor produces one value, andgetSetCookie()gives it back as a single-element array containing both cookies glued together. The folding is not reversible at the API layer. getSetCookie()reads; it does not parse. You still get the raw header value including attributes. If you needPath,ExpiresorSameSiteas fields, you need a cookie parser on top.- Availability. MDN lists
Headers.getSetCookie()as Baseline widely available since September 2023, and it is present in current Node —typeof headers.getSetCookieis'function'on v26.2.0. On a runtime old enough to lack it, thenode:httparray orrawHeadersis the fallback, notget().