Skip to content

Bug: Set-Cookie and multi-value header conversion drops, splits and collapses response headers in the HTTP event handler #5674

Description

@svozza

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

  1. 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.
  2. Register a route returning a Response with two Set-Cookie headers appended. Resolve; observe only the last cookie.
  3. Register a route returning a proxy result with two cookies and call resolveStream(); observe only the last cookie in the prelude.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions