Skip to main content

The mistake we kept making

auth.handler(request) takes a Fetch Request and returns a Fetch Response. The instinctive Hono pattern is to wrap it:
app.all("/api/auth/*", async (c) => {
  const response = await auth.handler(new Request(c.req.raw.url, {...}));
  response.headers.forEach((v, k) => c.header(k, v));
  const body = await response.text();
  return new Response(body, { status: response.status });
});
This looks fine. It is catastrophically wrong. Two failure modes, each worth a finding:
  1. Multi-valued Set-Cookie headers collapse. Headers.forEach yields every Set-Cookie separately, c.header(k, v) is last-write-wins, only the last cookie survives. BetterAuth sets state + csrf + session cookies on /sign-in/social and the OAuth callback; losing any of them breaks OAuth state mismatch, silent session loss, or phantom CSRF errors.
  2. Hono’s context headers are ignored when the handler returns its own Response. All those c.header() calls are dead code. The new Response(body, { status }) ships with exactly zero headers from the context. CORS headers set by upstream middleware never reach the outgoing response. Cross-origin getSession() calls silently fail in the browser even though the preflight succeeded and the body is correct.

What works

app.all("/api/auth/*", async (c) => {
  const response = await auth.handler(c.req.raw);

  const headers = new Headers();
  for (const cookie of response.headers.getSetCookie()) {
    headers.append("set-cookie", cookie);
  }
  response.headers.forEach((value, key) => {
    if (key.toLowerCase() === "set-cookie") return;
    headers.set(key, value);
  });
  applyCorsHeaders(c.req.header("origin") || "", headers);

  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers,
  });
});
Three rules that make it work:
  • Pass c.req.raw directly. Never reconstruct the request — the original has the right method, headers, body stream, and URL.
  • Enumerate cookies via Headers.getSetCookie() (a native Fetch spec API, not a Node extension). It returns a string[] of every Set-Cookie value, and then headers.append("set-cookie", v) preserves them.
  • Apply CORS explicitly on the Headers bag. Hono context headers only apply to responses Hono constructs for you. If you build a new Response(), you own the headers.

Lesson

Treat auth.handler as a sealed black box. Pass the request in, take the response out, mutate only via new Headers() copy. Any time you find yourself reaching for c.header() inside a BetterAuth route handler, you’re about to write a bug.

Consequence for future work

Every new framework integration (Hono, Elysia, Oak, H3) should start from this doctrine. Hubble already used the same sealed-handler pattern — revisit any custom-wrapped BetterAuth mount point in other services and apply this fix.