Skip to content

set res() setter loses Set-Cookie headers when middleware sets cookies before next() and handler returns a raw Response #4992

Description

@techzealot

What version of Hono are you using?

4.12.18

What runtime/platform is your app running on? (with version if possible)

Node.js v24.1.0

What steps can reproduce the bug?

When a middleware sets a Set-Cookie header via c.header() before await next(), and the downstream handler returns a raw Response object with its own Set-Cookie headers, one side's cookies are always lost — they are never merged correctly.

Minimal reproduction (no third-party dependencies):

import { Hono } from 'hono'

const app = new Hono()

// Middleware: access c.res (e.g. CORS does this), then set a cookie
app.use(async (c, next) => {
  c.res.headers.set('X-Something', 'true') // triggers #res creation
  c.header('Set-Cookie', 'mw_cookie=hello; Path=/', { append: true })
  await next()
})

// Handler: returns a raw Response with its own Set-Cookie
app.get('/test', (c) => {
  const res = new Response('ok')
  res.headers.append('set-cookie', 'handler_cookie=world; Path=/; HttpOnly')
  return res
})

const res = await app.request('/test')
console.log(res.headers.getSetCookie())
// Actual:   ['mw_cookie=hello; Path=/']
// Expected: ['mw_cookie=hello; Path=/', 'handler_cookie=world; Path=/; HttpOnly']

What is the expected behavior?

Both Set-Cookie headers should be present in the final response:

  • mw_cookie=hello (set by middleware via c.header())
  • handler_cookie=world (set by the handler's raw Response)

What do you see instead?

Only one side's cookies survive, depending on whether c.res was accessed before c.header():

Scenario #res created before c.header()? Lost cookies
A: No middleware touches c.res early No c.header() cookies lost (merge skipped entirely)
B: Some middleware accesses c.res early (e.g. CORS) Yes Handler's cookies overwritten

Additional information

Root cause

The bug is in the set res() setter in src/context.ts:

set res(_res) {
  if (this.#res && _res) {          // ← Scenario A: #res is falsy → entire merge is skipped
    _res = createResponseInstance(_res.body, _res)
    for (const [k, v] of this.#res.headers.entries()) {
      if (k === 'set-cookie') {
        const cookies = this.#res.headers.getSetCookie()
        _res.headers.delete('set-cookie')   // ← Scenario B: deletes handler's cookies
        for (const cookie of cookies) {
          _res.headers.append('set-cookie', cookie)  // ← only re-appends context's cookies
        }
      }
      // ...
    }
  }
  this.#res = _res
}

Scenario Athis.#res is undefined (no middleware accessed c.res before c.header()):

  • c.header() writes to #preparedHeaders, but the if (this.#res && _res) guard is false
  • #preparedHeaders are never merged into the handler's Response → middleware cookies lost

Scenario Bthis.#res exists (e.g. CORS middleware called c.res.headers.set() earlier):

  • _res.headers.delete('set-cookie') wipes ALL cookies from the handler's Response
  • Only the context's cookies are re-appended → handler cookies lost

Suggested fix

The merge logic should preserve cookies from both sides:

// this only fix Scenario B
if (k === 'set-cookie') {
  // Collect cookies from both sources before mutating
  const handlerCookies = _res.headers.getSetCookie()
  const contextCookies = this.#res.headers.getSetCookie()
  _res.headers.delete('set-cookie')
  for (const cookie of handlerCookies) {
    _res.headers.append('set-cookie', cookie)
  }
  for (const cookie of contextCookies) {
    _res.headers.append('set-cookie', cookie)
  }
}

Additionally, Scenario A needs #preparedHeaders to be merged even when #res hasn't been created yet.

Real-world impact

This bug surfaces in common middleware combinations. For example, in our project:

CORS middleware (accesses c.res.headers)
  → languageDetector({ caches: true })   // calls setCookie() → c.header("Set-Cookie", ...)
    → better-auth handler                // returns raw Response with session Set-Cookie headers

With caches: true, the languageDetector sets a language cookie via c.header() before next(). When better-auth's handler returns a raw Response with session cookies, the set-cookie merge in set res() causes cookies from one side to be dropped.

We currently work around this by setting caches: false, but this disables a legitimate feature. The issue is not specific to languageDetector or better-auth — any middleware that calls c.header('Set-Cookie', ...) before next() combined with any handler that returns a raw Response with Set-Cookie headers will trigger this bug.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions