Expected Behavior
Response headers should reach the client exactly as the handler or middleware produced them, on every integration and in both buffered and streaming modes. In particular:
- Every
Set-Cookie header is delivered as its own header line, one per cookie, whether it came from a proxy result's cookies array, from Headers.append('Set-Cookie', ...) on a returned Response, or contains a comma (an Expires date).
- Every other header is delivered with its value untouched.
- On ALB, the response uses whichever of
headers or multiValueHeaders the target group is configured to read.
Current Behavior
Four defects with one root cause: the converters treat Set-Cookie as an ordinary combinable header and treat commas in other headers as separators.
1. Cookies containing a comma are split into two bogus cookies. webHeadersToApiGatewayV1Headers (converters.ts:240) and webResponseToProxyResultV2 (converters.ts:367) split each Set-Cookie value on ,. RFC 6265 Expires dates contain a comma:
Set-Cookie: a=1; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/
arrives at the client as:
Set-Cookie: a=1; Expires=Wed
Set-Cookie: 21 Oct 2015 07:28:00 GMT; Path=/
2. Multiple Set-Cookie headers on a returned Response collapse to the last one. handlerResultToWebResponse (converters.ts:500) merges the returned Response's headers into the context headers with headers.set(key, value) while iterating entries(). entries() yields set-cookie once per cookie, so each set overwrites the previous:
const headers = new Headers();
headers.append('set-cookie', 'a=1; Path=/');
headers.append('set-cookie', 'b=2; Path=/');
return new Response('ok', { headers }); // client receives only b=2
3. Streaming responses keep only the last cookie. webHeadersToApiGatewayV2Headers (converters.ts:289) builds a plain record with headers[key] = value, so repeated set-cookie entries overwrite each other, and the streaming prelude's cookies array is never populated.
4. On ALB, headers are lost in one mode or the other. webHeadersToApiGatewayV1Headers (converters.ts:257-270) moves any allow-listed header containing a comma into multiValueHeaders. ALB reads only headers when multi-value headers are off and only multiValueHeaders when they are on, so there is no ALB configuration in which this response arrives intact:
app.post('/login', () => ({
statusCode: 200,
headers: { 'cache-control': 'no-store, private' },
cookies: ['session=abc; HttpOnly; Path=/', 'csrf=xyz; Path=/'],
body: 'ok',
}));
Today's output:
{
"headers": { "content-type": "application/json" },
"multiValueHeaders": {
"cache-control": ["no-store", "private"],
"set-cookie": ["session=abc; HttpOnly; Path=/", "csrf=xyz; Path=/"]
}
}
Multi-value off: the client gets content-type only. Multi-value on: the client gets cache-control and both cookies but no content-type. The CORS middleware's access-control-allow-methods and access-control-allow-headers take the same path, so ALB preflight is broken with multi-value headers off, which is the default and the one integration where our docs say the function must handle preflight itself.
Why this keeps recurring
This is the third pass over this code. #4986 / #4990 fixed the semicolon split breaking cookie attributes by adopting getSetCookie(), but kept a comma split on its output because the test fixtures modelled two cookies as one comma-joined string, and those fixtures were updated rather than removed. #5256 / #5257 fixed Date headers being split on their comma by adding an allowlist, and scoped cookies out as already handled. The Expires attribute inside a cookie sits at the intersection and each fix treated it as the other one's problem. Two of the four code paths were never touched by either.
Each pass added a rule about commas. The correct model has no rules about commas.
The model
RFC 9110 section 5.3: a recipient may combine repeated header lines into one line joined by commas "without changing the semantics of the message". Every header allowed to repeat is defined with list syntax so that this holds. Vary: A plus Vary: B is by definition the same message as Vary: A, B. The same section names the single exception: Set-Cookie does not follow list syntax and cannot be combined, because RFC 6265 allows commas inside a cookie value.
The Fetch Headers object implements exactly this. entries() returns every header already combined with , , and getSetCookie() returns cookies as a list. So:
- Never join. Non-cookie values from
Headers are already combined. Copy them verbatim.
- Never split. Splitting is unsafe even with an allowlist, because list elements may contain quoted commas.
Cache-Control: no-cache="set-cookie, vary" and WWW-Authenticate: Basic realm="a, b" are both valid.
Set-Cookie is the only list. Take it from getSetCookie() and never touch it.
multiValueHeaders is transport, not a decision. It exists because a JSON object cannot repeat a key, and the only header that needs repeating is Set-Cookie. The v2 payload confirms this: it has no multiValueHeaders, only a cookies array.
This is also what the other mainstream adapters do. Hono's aws-lambda adapter and the Rust runtime's lambda_http (which backs the Lambda Web Adapter, and therefore Bun on Lambda) both take cookies from the header list, never split, and have no allowlist.
Per target
| Header |
API Gateway REST |
ALB, event has multiValueHeaders |
ALB, event has headers only |
HTTP API / streaming |
| Non-cookie |
headers[name] = value |
multiValueHeaders[name] = [value] |
headers[name] = value |
headers[name] = value |
| One cookie |
headers['set-cookie'] |
multiValueHeaders['set-cookie'] = [c] |
headers['set-cookie'] |
cookies = [c] |
| Several cookies |
multiValueHeaders['set-cookie'] = [...] |
multiValueHeaders['set-cookie'] = [...] |
not representable, see below |
cookies = [...] |
Two choices in that table deserve a word:
ALB mode is detected from the event, not mirrored. ALB puts request headers in multiValueHeaders if and only if the target group has multi-value headers enabled, and reads the matching field from the response. So the incoming event tells us which field ALB will read, and we emit exactly one. Hono uses the same detection. Mirroring every header into both fields would also work for ALB, but emitting one field is simpler and avoids any question of which one wins.
REST keeps headers as the primary field. API Gateway REST reads both fields and merges them, deduplicating only identical key-value pairs. Emitting the same header in both would produce duplicate lines the moment a wrapper or downstream middleware overrides result.headers[...], which is a common pattern. lambda_http avoids that by emitting multiValueHeaders only; we keep non-cookie headers in headers, where post-processors expect them, and use multiValueHeaders only when there are two or more cookies. That is the current behaviour for cookies, so it is the smaller change.
Platform limit to document. On ALB with multi-value headers off there is no way to return more than one cookie. That is ALB's constraint. We should not paper over it by shipping one cookie out of two; the docs should say: on ALB, enable multi-value headers on the target group if you set more than one cookie.
Code snippet
import { Router } from '@aws-lambda-powertools/event-handler/http';
import type { Context } from 'aws-lambda';
const app = new Router();
app.get('/expires', () => ({
statusCode: 200,
body: 'ok',
cookies: ['a=1; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/'],
}));
app.get('/login', () => {
const headers = new Headers();
headers.append('set-cookie', 'a=1; Path=/');
headers.append('set-cookie', 'b=2; Path=/');
return new Response('ok', { headers });
});
export const handler = async (event: unknown, context: Context) =>
app.resolve(event, context);
Failing round-trip tests:
it('does not split a cookie with an Expires attribute on the comma', async () => {
// Prepare
const app = new Router();
const cookie = 'a=1; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/';
app.get('/login', () => ({ statusCode: 200, body: 'ok', cookies: [cookie] }));
// Act
const v1 = await app.resolve(createTestEvent('/login', 'GET'), context);
const v2 = await app.resolve(createTestEventV2('/login', 'GET'), context);
// Assess
expect(v1.headers?.['set-cookie']).toBe(cookie);
expect(v2.cookies).toEqual([cookie]);
});
it('keeps every Set-Cookie header of a returned Response', async () => {
// Prepare
const app = new Router();
app.get('/login', () => {
const headers = new Headers();
headers.append('set-cookie', 'a=1; Path=/');
headers.append('set-cookie', 'b=2; Path=/');
return new Response('ok', { headers });
});
// Act
const result = await app.resolve(createTestEventV2('/login', 'GET'), context);
// Assess
expect(result.cookies).toEqual(['a=1; Path=/', 'b=2; Path=/']);
});
it('streams every cookie in the response prelude', async () => {
// Prepare
const app = new Router();
app.get('/login', () => ({ statusCode: 200, body: 'ok', cookies: ['a=1', 'b=2'] }));
const responseStream = new ResponseStream();
// Act
await app.resolveStream(createTestEventV2('/login', 'GET'), context, { responseStream });
// Assess
const output = responseStream.getBuffer().toString();
expect(output).toContain('a=1');
expect(output).toContain('b=2');
});
it('keeps comma-list headers in headers for an ALB event without multiValueHeaders', async () => {
// Prepare
const app = new Router();
app.get('/test', () => ({
statusCode: 200,
body: 'ok',
headers: { 'cache-control': 'no-store, private' },
}));
// Act
const result = await app.resolve(createTestALBEvent('/test', 'GET'), context);
// Assess
expect(result.headers?.['cache-control']).toBe('no-store, private');
expect(result.multiValueHeaders).toBeUndefined();
});
Steps to Reproduce
- Register a route returning a proxy result with a cookie whose value contains a comma, such as an
Expires attribute. Resolve with a v1 or v2 event; observe two cookies.
- Register a route returning a
Response with two Set-Cookie headers appended. Resolve; observe only the last cookie.
- Register a route returning a proxy result with two
cookies and call resolveStream(); observe only the last cookie in the prelude.
- Register a route returning
cache-control: no-store, private and resolve with an ALB event; observe the header is absent from headers.
Possible Solution
Replace the per-target header logic with one projection from Headers in which Set-Cookie is the only list-valued header:
- One function turns
Headers into { headers: Record<string, string>; cookies: string[] }, using entries() for non-cookie headers verbatim and getSetCookie() for cookies. This is the only place that knows about set-cookie.
- REST, ALB, v2, and the streaming prelude become thin projections of that shape per the table above. ALB picks its field from the presence of
multiValueHeaders on the incoming event.
- Merging a returned
Response into reqCtx.res in handlerResultToWebResponse uses set for non-cookie headers and append for each cookie.
- Delete
MULTI_VALUE_HEADERS_ALLOWLIST, the access-control- prefix rule, and both split(',') calls.
- The inbound side (
populateV1Headers) is the same shape reversed: append each multiValueHeaders value, set headers that appear only in headers, and drop the substring includes deduplication that reorders repeated headers today.
Tests: delete the converter fixtures that model several cookies as one comma-joined string rather than updating them, since they encode the wrong model and are how the comma split survived two fixes. Prefer round-trip tests through Router.resolve with RFC example values.
Before merging, verify with a throwaway ALB stack that the event carries multiValueHeaders exactly when the target group has multi-value headers enabled, and that ALB reads the matching response field. This is the load-bearing claim behind the mode detection.
The wire output is unchanged for every response that works today, so this fits in v2. The only visible change to anyone inspecting the raw Lambda result is that list headers on REST stay in headers as 'a, b' rather than moving to multiValueHeaders as ['a', 'b'], which is spec-equivalent on the wire and makes those headers visible to downstream code that reads result.headers.
Powertools for AWS Lambda (TypeScript) version
2.35.0
AWS Lambda function runtime
24.x
Packaging format used
npm
Execution logs
None; no error is raised. Headers and cookies are silently dropped or corrupted in the response.
Expected Behavior
Response headers should reach the client exactly as the handler or middleware produced them, on every integration and in both buffered and streaming modes. In particular:
Set-Cookieheader is delivered as its own header line, one per cookie, whether it came from a proxy result'scookiesarray, fromHeaders.append('Set-Cookie', ...)on a returnedResponse, or contains a comma (anExpiresdate).headersormultiValueHeadersthe target group is configured to read.Current Behavior
Four defects with one root cause: the converters treat
Set-Cookieas an ordinary combinable header and treat commas in other headers as separators.1. Cookies containing a comma are split into two bogus cookies.
webHeadersToApiGatewayV1Headers(converters.ts:240) andwebResponseToProxyResultV2(converters.ts:367) split eachSet-Cookievalue on,. RFC 6265Expiresdates contain a comma:arrives at the client as:
2. Multiple
Set-Cookieheaders on a returnedResponsecollapse to the last one.handlerResultToWebResponse(converters.ts:500) merges the returnedResponse's headers into the context headers withheaders.set(key, value)while iteratingentries().entries()yieldsset-cookieonce per cookie, so eachsetoverwrites the previous:3. Streaming responses keep only the last cookie.
webHeadersToApiGatewayV2Headers(converters.ts:289) builds a plain record withheaders[key] = value, so repeatedset-cookieentries overwrite each other, and the streaming prelude'scookiesarray is never populated.4. On ALB, headers are lost in one mode or the other.
webHeadersToApiGatewayV1Headers(converters.ts:257-270) moves any allow-listed header containing a comma intomultiValueHeaders. ALB reads onlyheaderswhen multi-value headers are off and onlymultiValueHeaderswhen they are on, so there is no ALB configuration in which this response arrives intact:Today's output:
{ "headers": { "content-type": "application/json" }, "multiValueHeaders": { "cache-control": ["no-store", "private"], "set-cookie": ["session=abc; HttpOnly; Path=/", "csrf=xyz; Path=/"] } }Multi-value off: the client gets
content-typeonly. Multi-value on: the client getscache-controland both cookies but nocontent-type. The CORS middleware'saccess-control-allow-methodsandaccess-control-allow-headerstake the same path, so ALB preflight is broken with multi-value headers off, which is the default and the one integration where our docs say the function must handle preflight itself.Why this keeps recurring
This is the third pass over this code. #4986 / #4990 fixed the semicolon split breaking cookie attributes by adopting
getSetCookie(), but kept a comma split on its output because the test fixtures modelled two cookies as one comma-joined string, and those fixtures were updated rather than removed. #5256 / #5257 fixedDateheaders being split on their comma by adding an allowlist, and scoped cookies out as already handled. TheExpiresattribute inside a cookie sits at the intersection and each fix treated it as the other one's problem. Two of the four code paths were never touched by either.Each pass added a rule about commas. The correct model has no rules about commas.
The model
RFC 9110 section 5.3: a recipient may combine repeated header lines into one line joined by commas "without changing the semantics of the message". Every header allowed to repeat is defined with list syntax so that this holds.
Vary: AplusVary: Bis by definition the same message asVary: A, B. The same section names the single exception:Set-Cookiedoes not follow list syntax and cannot be combined, because RFC 6265 allows commas inside a cookie value.The Fetch
Headersobject implements exactly this.entries()returns every header already combined with,, andgetSetCookie()returns cookies as a list. So:Headersare already combined. Copy them verbatim.Cache-Control: no-cache="set-cookie, vary"andWWW-Authenticate: Basic realm="a, b"are both valid.Set-Cookieis the only list. Take it fromgetSetCookie()and never touch it.multiValueHeadersis transport, not a decision. It exists because a JSON object cannot repeat a key, and the only header that needs repeating isSet-Cookie. The v2 payload confirms this: it has nomultiValueHeaders, only acookiesarray.This is also what the other mainstream adapters do. Hono's
aws-lambdaadapter and the Rust runtime'slambda_http(which backs the Lambda Web Adapter, and therefore Bun on Lambda) both take cookies from the header list, never split, and have no allowlist.Per target
multiValueHeadersheadersonlyheaders[name] = valuemultiValueHeaders[name] = [value]headers[name] = valueheaders[name] = valueheaders['set-cookie']multiValueHeaders['set-cookie'] = [c]headers['set-cookie']cookies = [c]multiValueHeaders['set-cookie'] = [...]multiValueHeaders['set-cookie'] = [...]cookies = [...]Two choices in that table deserve a word:
ALB mode is detected from the event, not mirrored. ALB puts request headers in
multiValueHeadersif and only if the target group has multi-value headers enabled, and reads the matching field from the response. So the incoming event tells us which field ALB will read, and we emit exactly one. Hono uses the same detection. Mirroring every header into both fields would also work for ALB, but emitting one field is simpler and avoids any question of which one wins.REST keeps
headersas the primary field. API Gateway REST reads both fields and merges them, deduplicating only identical key-value pairs. Emitting the same header in both would produce duplicate lines the moment a wrapper or downstream middleware overridesresult.headers[...], which is a common pattern.lambda_httpavoids that by emittingmultiValueHeadersonly; we keep non-cookie headers inheaders, where post-processors expect them, and usemultiValueHeadersonly when there are two or more cookies. That is the current behaviour for cookies, so it is the smaller change.Platform limit to document. On ALB with multi-value headers off there is no way to return more than one cookie. That is ALB's constraint. We should not paper over it by shipping one cookie out of two; the docs should say: on ALB, enable multi-value headers on the target group if you set more than one cookie.
Code snippet
Failing round-trip tests:
Steps to Reproduce
Expiresattribute. Resolve with a v1 or v2 event; observe two cookies.Responsewith twoSet-Cookieheaders appended. Resolve; observe only the last cookie.cookiesand callresolveStream(); observe only the last cookie in the prelude.cache-control: no-store, privateand resolve with an ALB event; observe the header is absent fromheaders.Possible Solution
Replace the per-target header logic with one projection from
Headersin whichSet-Cookieis the only list-valued header:Headersinto{ headers: Record<string, string>; cookies: string[] }, usingentries()for non-cookie headers verbatim andgetSetCookie()for cookies. This is the only place that knows aboutset-cookie.multiValueHeaderson the incoming event.ResponseintoreqCtx.resinhandlerResultToWebResponseusessetfor non-cookie headers andappendfor each cookie.MULTI_VALUE_HEADERS_ALLOWLIST, theaccess-control-prefix rule, and bothsplit(',')calls.populateV1Headers) is the same shape reversed: append eachmultiValueHeadersvalue, set headers that appear only inheaders, and drop the substringincludesdeduplication that reorders repeated headers today.Tests: delete the converter fixtures that model several cookies as one comma-joined string rather than updating them, since they encode the wrong model and are how the comma split survived two fixes. Prefer round-trip tests through
Router.resolvewith RFC example values.Before merging, verify with a throwaway ALB stack that the event carries
multiValueHeadersexactly when the target group has multi-value headers enabled, and that ALB reads the matching response field. This is the load-bearing claim behind the mode detection.The wire output is unchanged for every response that works today, so this fits in v2. The only visible change to anyone inspecting the raw Lambda result is that list headers on REST stay in
headersas'a, b'rather than moving tomultiValueHeadersas['a', 'b'], which is spec-equivalent on the wire and makes those headers visible to downstream code that readsresult.headers.Powertools for AWS Lambda (TypeScript) version
2.35.0
AWS Lambda function runtime
24.x
Packaging format used
npm
Execution logs
None; no error is raised. Headers and cookies are silently dropped or corrupted in the response.