~/blog

Object.fromEntries(headers) silently drops your session cookie

published

#http#cookies#node#fetch

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-Cookie are 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 for Set-Cookie headers.

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 pathResultCookies preserved
headers.getSetCookie()array of 2 strings2 of 2
headers.get('set-cookie')one comma-joined string2, but not separable
Object.fromEntries(headers) or new Map(headers)one string, last value only1 of 2
spread of headers into an entry arraytwo set-cookie entries2 of 2
Node res.headers['set-cookie'] from node:httparray of 2 strings2 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

References