Skip to content

Multiple Set-Cookie headers collapsed into single malformed header in 304 responses #15527

@FredKSchott

Description

@FredKSchott

Describe the bug

SvelteKit's 304 Not Modified response handling collapses multiple Set-Cookie headers into a single comma-joined header, violating RFC 6265 and causing cookie loss. When a route sets multiple cookies and a subsequent request triggers the 304 path (via If-None-Match), browsers receive malformed Set-Cookie headers that result in partial or complete cookie loss, breaking authentication, CSRF protection, and user state management.

The issue is in packages/kit/src/runtime/server/respond.js at lines 485-495. The header copying loop uses Headers.get('set-cookie') which collapses multiple Set-Cookie headers into a comma-joined string, then writes that as a single header via headers.set().

On non-Node adapters (adapter-cloudflare, adapter-vercel, adapter-netlify), this causes complete loss of all cookies after the first. On adapter-node, splitCookiesString() provides partial heuristic-based recovery, so the bug is masked when testing locally with adapter-node.

Reproduction

The bug is in the 304 header-copying code at respond.js:485-495. Save this as repro.mjs and run with node repro.mjs — it reproduces the exact logic from that code path:

// Simulates what respond.js does when building a 304 response
const originalHeaders = new Headers();
originalHeaders.append('set-cookie', 'session=abc123; Path=/; HttpOnly');
originalHeaders.append('set-cookie', 'csrf=xyz789; Path=/; SameSite=Strict');
originalHeaders.append('set-cookie', 'locale=en; Path=/');
originalHeaders.set('etag', '"test123"');

console.log('Original Set-Cookie count:', originalHeaders.getSetCookie().length);
// → 3

// This is what respond.js lines 485-495 do:
const etag = originalHeaders.get('etag');
const headers304 = new Headers({ etag });
for (const key of ['cache-control', 'content-location', 'date', 'expires', 'vary', 'set-cookie']) {
	const value = originalHeaders.get(key);  // ← .get() collapses Set-Cookie per spec
	if (value) headers304.set(key, value);
}

console.log('304 Set-Cookie count:', headers304.getSetCookie().length);
// → 1 (should be 3)
console.log('304 Set-Cookie value:', headers304.getSetCookie());
// → ['session=abc123; Path=/; HttpOnly, csrf=xyz789; Path=/; SameSite=Strict, locale=en; Path=/']

Output:

Original Set-Cookie count: 3
304 Set-Cookie count: 1
304 Set-Cookie value: [
  'session=abc123; Path=/; HttpOnly, csrf=xyz789; Path=/; SameSite=Strict, locale=en; Path=/'
]

Three cookies become one malformed comma-joined string. Headers.get('set-cookie') joins multiple values per the Fetch spec — getSetCookie() exists specifically to avoid this, but the 304 path doesn't use it.

Note: If you test this with curl against adapter-node, the cookies may appear correct on the wire because Node's HTTP layer applies splitCookiesString() heuristic recovery after SvelteKit hands off the collapsed header. The bug is still present in SvelteKit's output — it's just masked by a downstream workaround that doesn't exist on other adapters.

Logs

No errors — the malformed header is silently served. Browsers parse only the first cookie from the comma-joined value, silently dropping the rest.

System Info

System:
  OS: macOS 26.2
  CPU: (14) arm64 Apple M4 Pro
Binaries:
  Node: 24.13.0
  pnpm: 10.28.1
npmPackages:
  @sveltejs/kit: ^2.50.2 => 2.53.4
  svelte: ^5.51.0 => 5.53.7
  vite: ^7.3.1 => 7.3.1

Most severe on: adapter-cloudflare, adapter-vercel, adapter-netlify (no recovery)
Partially mitigated on: adapter-node (splitCookiesString recovery)

Severity

serious, but I can work around it

Additional Information

Fix — replace the header copying with Set-Cookie-aware handling:

// Instead of:
const value = response.headers.get(key);
if (value) headers.set(key, value);

// Use getSetCookie() for set-cookie:
const cookies = response.headers.getSetCookie();
for (const cookie of cookies) {
	headers.append('set-cookie', cookie);
}

The intermittent nature (only on 304 responses) makes this particularly hard to debug — it appears as "cookies sometimes don't get set" and affects returning users more than new users.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions