Duplicate HTTP Headers Sent as an Array: How to Debug Them
Quick answer: When a header value is an array, an HTTP client can send it as several header lines with the same name, which the server reads as a list or a comma-joined string. The bug is invisible in your header object, so inspect the raw bytes on the wire, find every place the header is added, and pin one rule for how list values are serialized and parsed.
What a duplicate header looks like on the wire
HTTP allows more than one field line with the same name. For headers that use list syntax, these two requests carry the same meaning:
| Wire format | How it is written |
|---|---|
| Repeated lines | X-Tag: alpha then X-Tag: beta |
| Comma-joined line | X-Tag: alpha, beta |
A recipient can combine repeated lines by joining the values with commas in order. That mapping between wire format and the array your framework hands you is the core: the same header can look like two lines, one line, or an array depending on the layer you inspect.
Why one header becomes an array
Three causes account for most cases. The client serializes an array value, where one library emits repeated lines, another joins with commas, and a third keeps only the last value. A header is added in more than one place, such as a global interceptor and a call site that both set a trace ID, producing two lines. A proxy or gateway then rewrites the field, merging, dropping, or reordering values so a request that works locally fails after it crosses the edge.
How frameworks expose repeated headers
Frameworks do not agree on the in-memory shape, which is why checking only your language's header object can mislead you.
| Runtime or library | How repeated values are exposed |
|---|---|
Go net/http |
http.Header is a map of string slices; Get returns the first value |
Node core http |
Most fields are merged into one comma-joined string; set-cookie stays an array |
Fetch and undici Headers |
get returns the combined value; getSetCookie is separate |
| Python (Flask, Werkzeug) | getlist returns the separate values; get returns the joined form |
Note the special case for Set-Cookie. It cannot be merged with commas because cookie attributes already contain commas, so it stays separate lines; treating it like an ordinary list header is a common source of broken sessions.
Where the failure shows up
A header that silently becomes a list breaks things in predictable spots. Request signing fails when the client and server join or order the values differently, yielding a 401 even though the token is valid. An auth or routing header added twice can make a gateway pick the first or last value and route to the wrong tenant. A value that legitimately contains a comma becomes ambiguous once joined. The tell is a mismatch between what your code thinks it sent and what the upstream reports, which points at serialization rather than business logic.
How to see the real bytes and fix it
Read the request at the HTTP layer rather than the header object. curl -v shows each line as it goes out, and repeating -H sends repeated lines on purpose:
curl -v https://api.example.com/items \
-H 'X-Tag: alpha' -H 'X-Tag: beta'
A desktop API workspace helps because it sends the request through the local main process and keeps the raw request and response as evidence, so you can compare the lines against what the server logged. Powerduck works this way, making the wire format the thing you debug instead of a guessed array.
Once you can see the lines, apply the fixes:
| Do | Why |
|---|---|
| Declare each header single-value or a list | Stops two layers from guessing |
| Set single-value headers in one place | Prevents an interceptor and call site from stacking |
| Pick one list encoding and document it | Repeated lines or comma join, used on both ends |
| Canonicalize headers before signing | Fixed order, trimming, and separator on client and server |
| Quote or encode values containing commas | Removes ambiguity after joining |
| Test through the real gateway | Proxies can merge or drop fields |
Honest limits
Seeing the array tells you the header repeated, not which layer produced it. The duplicate can originate in the client, a proxy, or the gateway, so logs and a raw capture remain the source of truth. A passing desktop request proves the endpoint answers, not that every hop preserves the header. Verify end to end, including a header set twice, since in-memory unit tests never exercise the wire and proxy behavior.
Frequently asked questions
Are duplicate HTTP headers legal? Yes. Repeated field lines are allowed, and for list headers a receiver may merge them with commas. The exception is Set-Cookie, which stays separate.
Why does my header value contain a comma I never added? The client or a proxy merged repeated lines into one comma-joined string. Inspect the raw request to see whether you sent two lines or one.
Should I send a list as repeated lines or one joined header? Either works if both ends agree, but one explicit, documented encoding is safer than relying on each library's default, especially when signing or crossing proxies.
The bottom line
Treat a header that arrives as an array as a serialization problem: confirm the wire bytes with a raw capture, trace every place the field is added, and lock one rule for list encoding and canonicalization on both ends. The in-memory array is only a representation; the repeated lines and the proxies in between determine whether the request is accepted. For more, see the API debugging guide hub.