skip to content

Why must multiple HTTP 'Set-Cookie' response headers never be folded into a single comma-separated header line, and what does that imply for HTTP client library APIs?

level: middleimportance: should knowfreq 34%

answer

  1. Expires HTTP-date contains a comma → folding is ambiguous
  2. Set-Cookie is not a comma-separated list header
  3. need getAll / array / getSetCookie(), not getHeader
  4. addHeader not setHeader in servlet loops
  5. HTTP/2-3: never join set-cookie; DO rejoin split cookie requests with '; '

basics

~20 s

The Expires attribute uses an HTTP-date that itself contains a comma ('Wed, 09 Jun 2027 10:18:14 GMT'), so comma-joining Set-Cookie values is ambiguous and cannot be split back reliably. Client libraries must expose a get-all-values API; a single getHeader('Set-Cookie') loses cookies or corrupts them.

solid answer

~50 s

HTTP normally allows repeated header fields to be combined into one field with comma-separated values. `Set-Cookie` is the famous exception: its `Expires` attribute is an `HTTP-date` such as `Wed, 09 Jun 2027 10:18:14 GMT`, which **contains a comma**. Fold two `Set-Cookie` values together and no parser can reliably tell a cookie boundary from a comma inside a date. RFC 6265 therefore says origin servers should not fold multiple `Set-Cookie` fields, and receivers must handle them as separate fields.\n\nThe API consequence is concrete: any header accessor that returns a single string per name is wrong for `Set-Cookie`. Libraries need `getAll`/`getHeaders`, a multimap, or a dedicated cookie jar — Node's `res.headers['set-cookie']` returns an array precisely for this reason, and `fetch`'s `Headers` needed `getSetCookie()` added because its normal `get()` joins values with commas.\n\nThe same holds in HTTP/2 and HTTP/3: multiple `Set-Cookie` field lines are carried separately, and any intermediary that concatenates them is broken.

code

http · 1 line
http
HTTP/1.1 200 OK\nSet-Cookie: sid=abc; Expires=Wed, 09 Jun 2027 10:18:14 GMT; Path=/\nSet-Cookie: csrf=xyz; Expires=Wed, 09 Jun 2027 10:18:14 GMT; Path=/

go deeper

for a junior

Know that each cookie needs its own Set-Cookie header and that they must not be merged.

for a middle

Give the reason — the comma inside the Expires date — and name the API consequence, such as Node returning an array or fetch needing getSetCookie().

for a senior

Describe the failure mode end to end (missing CSRF cookie after login), where it hides (gateways, mocks), and how you would test for it.

for a principal

Treat it as an interoperability constraint on your platform's HTTP abstractions: no single-string header maps on the response path, and conformance tests at every proxy hop.

## The general rule and the exception\n\nHTTP defines most repeated header fields as list-valued: `Accept: a` plus `Accept: b` is semantically identical to `Accept: a, b`, and proxies, servers and libraries are free to combine them. That single simplification is what lets header APIs be a plain `Map<String, String>`.\n\n`Set-Cookie` breaks it. Its grammar is not a comma-separated list, and its `Expires` attribute is an `HTTP-date`:\n\n```\nSet-Cookie: a=1; Expires=Wed, 09 Jun 2027 10:18:14 GMT\nSet-Cookie: b=2; Expires=Thu, 10 Jun 2027 10:18:14 GMT\n```\n\nFolded, that becomes:\n\n```\nSet-Cookie: a=1; Expires=Wed, 09 Jun 2027 10:18:14 GMT, b=2; Expires=Thu, 10 Jun 2027 10:18:14 GMT\n```\n\nSplitting on commas now yields four fragments, two of them meaningless. A parser can only recover the cookies with a heuristic — "a comma followed by something that looks like a weekday abbreviation and a date is not a boundary" — and heuristics fail on values that legitimately resemble dates. RFC 6265 accordingly instructs origin servers not to fold, and receivers to treat each `Set-Cookie` as its own field.\n\nNote the direction asymmetry: the **request** `Cookie` header *is* a single header with pairs separated by `\";\"`, so the problem is specific to the response side.\n\n## What this breaks in practice\n\n**Single-string header APIs.** Any API shaped `String getHeader(String name)` silently loses cookies when a response sets more than one. Real failures follow the same script: login sets a session cookie and a CSRF cookie; the client library returns only the first (or a mangled join); the CSRF cookie is missing; every subsequent POST is rejected; someone spends a day blaming CSRF configuration.\n\nCorrect APIs:\n\n- Node.js: `res.headers['set-cookie']` is an **array** — the one header special-cased in the HTTP parser.\n- Fetch/`Headers`: `get('set-cookie')` joins with commas, which is why **`getSetCookie()`** was added to the spec and shipped in browsers and runtimes.\n- Java servlet: `HttpServletResponse.addHeader(\"Set-Cookie\", …)` (append) versus `setHeader` (replace) — using `setHeader` in a loop is a classic way to end up with exactly one cookie.\n- Most languages also expose a cookie jar that does the parsing for you; prefer it over hand-splitting.\n\n**Proxies and gateways.** An intermediary that normalises headers into a map, or that helpfully joins repeated fields, will corrupt cookies. This shows up as "cookies work locally but not behind the gateway" and is worth testing explicitly with a response that sets two cookies, one of them carrying `Expires`.\n\n**Test doubles.** Mock HTTP layers that model headers as a dictionary hide the bug until production.\n\n## HTTP/2 and HTTP/3\n\nBinary framing does not change any of this. In HTTP/2 and HTTP/3 each `set-cookie` field line is carried as its own header field in the HPACK/QPACK block; a receiver must not concatenate them into one value. The specifications go out of their way to say so, because the naive "join repeated fields with a comma" rule that works for other headers would corrupt cookies here.\n\nThe **request** side gets its own special rule in the opposite direction: HTTP/2 and HTTP/3 permit the `cookie` header to be **split** across multiple field lines to improve compression efficiency, and require the receiver to rejoin them with `\"; \"` before handing the value to a generic HTTP/1.1-shaped API. So for cookies, the response header must never be joined and the request header must always be joined — the exact opposite of each other, which is why cookie handling belongs in a library rather than in your own code.\n\n## What to say and what to do\n\nState the reason (comma inside the `Expires` HTTP-date), state the rule (never fold `Set-Cookie`; each is its own field), and state the practical test: does your client expose a multi-value accessor? If your stack forces you to parse a joined string, use a maintained cookie parser rather than a regular expression — the date heuristic is exactly the kind of code that works for a year and then eats a session cookie.

  • HTTP/2 allows the request cookie header to be split across several field lines. Does that contradict the no-folding rule?
    No — the rules apply to different headers in different directions. The response set-cookie field lines must stay separate because their values are not a comma list. The request cookie header is a single logical value that HTTP/2 may split for better header compression, and the receiver must rejoin the pieces with '; ' before handing it to an HTTP/1.1-style API.
  • Your client library only exposes getHeader(name) returning one string. What now?
    Reach for whatever multi-value accessor exists underneath — an array, a header multimap, or a cookie jar — before parsing anything yourself. If you truly must split a joined string, use a maintained cookie parser that special-cases the Expires date rather than splitting on commas, and add a test with a response setting two cookies that both carry Expires.

saying these in an interview costs you the question

  • Claiming Set-Cookie can be folded like any other list header
  • Blaming the semicolon rather than the comma inside the Expires HTTP-date
  • Splitting a joined Set-Cookie string on commas with a regex and calling it done
  • Using setHeader instead of addHeader when emitting several cookies
  • Assuming HTTP/2 changes the rule because headers are binary

context