Hono Loses Session Cookies on Cross-Origin Requests Because `fetch` Never Sends Credentials

cookies, cors, fetch, hono, session

The browser sends the API request, the server returns 200 OK, and the session cookie still does not persist or come back on the next request. In Chrome DevTools the response may also show: This Set-Cookie was blocked because it had the "SameSite=Lax" attribute but came from a cross-site response or This Set-Cookie was blocked because it had no "Secure" attribute, and the network tab may show that the request was sent without credentials.

What breaks

Cookie-based session auth depends on two separate browser decisions:

  1. Whether the outgoing request is allowed to include cookies.
  2. Whether the incoming response is allowed to store cookies.

If either side is wrong, Hono can return a valid session response and the browser still discards the cookie or omits it on the next call.

For cross-origin requests, fetch() does not send credentials unless you ask for them. The default is credentials: 'same-origin', which means cookies are only sent when the request URL is same-origin with the page. For http://localhost:3000 calling http://localhost:8787, that is cross-origin, so no cookies are attached.

Even when the request includes credentials, the browser will ignore Set-Cookie unless the server sends the correct CORS headers. Access-Control-Allow-Origin cannot be * when credentials are involved. The response also needs Access-Control-Allow-Credentials: true.

For modern browsers, cross-site cookies also need the right cookie attributes. In practice, that means SameSite=None and Secure for cross-site use. Without SameSite=None, the browser treats the cookie as non-cross-site and drops it on cross-site requests. Without Secure, the cookie is rejected whenever SameSite=None is used.

Why this happens

Cookies are controlled by browser policy, not just by HTTP response codes.

A successful 200 OK from the API only proves the server handled the request. It does not prove the browser accepted the cookie or attached it later.

The relevant rules are:

The key point is that Set-Cookie is not governed by the same permissive rules as JSON responses. Browsers apply extra restrictions to prevent a random site from silently setting or reading cookies for another site.

The broken request pattern

A frontend on one origin calling a Hono API on another origin often starts with code like this:

ts
await fetch('http://localhost:8787/api/session', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ email, password }), })

This request is cross-origin, and it does not include credentials. The browser may still send the request body and receive the response, but it will not include cookies on the request or reliably store Set-Cookie from the response in a cross-origin credentialed flow.

The matching follow-up request has the same problem:

ts
await fetch('http://localhost:8787/api/me')

The browser will not attach the session cookie unless you opt in.

The required client-side fix

Use credentials: 'include' for every request that needs session cookies.

ts
await fetch('http://localhost:8787/api/session', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ email, password }), })

And for reads:

ts
await fetch('http://localhost:8787/api/me', { credentials: 'include', })

If you use a wrapper, preserve the option there as well:

ts
export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}) { return fetch(input, { ...init, credentials: 'include', }) }

If the frontend is using axios, the equivalent setting is withCredentials: true.

ts
import axios from 'axios' const api = axios.create({ baseURL: 'http://localhost:8787', withCredentials: true, })

Without this, the browser policy alone blocks session cookie flow across origins.

The required server-side CORS headers

Hono needs to emit CORS headers that permit credentialed requests. The cors middleware from hono/cors handles the response headers for you.

Install Hono if needed:

bash
npm install hono

Configure CORS with the exact frontend origin, not *:

ts
import { Hono } from 'hono' import { cors } from 'hono/cors' const app = new Hono() app.use( '/api/*', cors({ origin: 'http://localhost:3000', credentials: true, }) ) export default app

This produces the needed combination:

The origin must match the request Origin header exactly. That means scheme, host, and port all matter. http://localhost:3000 is not the same as http://127.0.0.1:3000, and https://localhost:3000 is different again.

Do not use origin: '*' with credentialed cookies. Browsers reject credentialed CORS responses that pair Access-Control-Allow-Credentials: true with a wildcard origin.

If multiple frontends are allowed, use a function instead of a wildcard.

ts
const allowedOrigins = new Set([ 'http://localhost:3000', 'https://app.example.com', ]) app.use( '/api/*', cors({ origin: (origin) => { if (origin && allowedOrigins.has(origin)) return origin return 'http://localhost:3000' }, credentials: true, }) )

The important part is that the returned origin is a single explicit origin string, not *.

If the browser is supposed to send the session cookie across origins, the cookie attributes must match that use case.

With Hono, set the cookie using setCookie from hono/cookie:

ts
import { Hono } from 'hono' import { setCookie } from 'hono/cookie' const app = new Hono() app.post('/api/session', async (c) => { const sessionId = 'sess_123' setCookie(c, 'session', sessionId, { httpOnly: true, secure: true, sameSite: 'None', path: '/', maxAge: 60 * 60 * 24 * 7, }) return c.json({ ok: true }) })

The important attributes are:

If the cookie is only used on the same site, a stricter SameSite value can work. But for cross-origin frontend-to-API requests, SameSite=None is the setting that allows the browser to attach the cookie.

Be careful with development. secure: true requires HTTPS in normal browser behavior. For local HTTP development, you may need one of these approaches:

The production configuration should not rely on browser exceptions.

Verifying the browser actually includes credentials

The browser DevTools network panel is the fastest way to check the request.

Inspect the request headers for:

Inspect the response headers for:

If the request lacks Cookie, the client code is missing credentials: 'include' or withCredentials: true.

If the response has Set-Cookie but the cookie does not appear in the browser storage, check the DevTools warnings. Chrome usually explains why the cookie was blocked.

A request that should work end to end looks like this:

ts
const login = await fetch('http://localhost:8787/api/session', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ email: 'dev@example.com', password: 'secret', }), }) console.log(login.status)

Then a session check:

ts
const me = await fetch('http://localhost:8787/api/me', { credentials: 'include', }) console.log(await me.json())

If /api/me sees the session cookie, the browser has accepted and resent it.

A complete Hono example

This example shows CORS, login, cookie creation, and a protected route.

ts
import { Hono } from 'hono' import { cors } from 'hono/cors' import { getCookie, setCookie } from 'hono/cookie' const app = new Hono() app.use( '/api/*', cors({ origin: 'http://localhost:3000', credentials: true, }) ) app.post('/api/session', async (c) => { const body = await c.req.json<{ email: string; password: string }>() if (body.email !== 'dev@example.com' || body.password !== 'secret') { return c.json({ error: 'invalid credentials' }, 401) } setCookie(c, 'session', 'sess_123', { httpOnly: true, secure: true, sameSite: 'None', path: '/', maxAge: 60 * 60 * 24 * 7, }) return c.json({ ok: true }) }) app.get('/api/me', (c) => { const session = getCookie(c, 'session') if (!session) { return c.json({ error: 'unauthorized' }, 401) } return c.json({ session }) }) export default app

A matching frontend call must include credentials:

ts
await fetch('http://localhost:8787/api/session', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ email: 'dev@example.com', password: 'secret', }), })

That combination is the baseline for browser-based session cookies across origins.

Common misconfigurations

A few settings consistently break this flow.

Access-Control-Allow-Origin: *

This is incompatible with credentialed requests. The browser blocks the response if the client includes credentials and the server answers with a wildcard origin.

Missing Access-Control-Allow-Credentials: true

The browser will not expose the response to credentialed code in the expected way, and cookie storage can fail in the cross-origin flow.

Missing credentials: 'include'

The request goes out without cookies. The server may still create a session, but the browser does not send the cookie back on later requests.

SameSite=Lax or SameSite=Strict

These settings are usually wrong for cross-site API calls. They block the browser from sending the cookie with cross-site requests.

secure: false with SameSite=None

Modern browsers reject SameSite=None cookies without Secure.

Origin mismatch

http://localhost:3000 and http://127.0.0.1:3000 are different origins. The CORS origin must match the browser Origin header exactly, or the browser will not treat the response as credential-safe.

Practical troubleshooting sequence

If the cookie is missing, check the system in this order:

  1. Confirm the frontend request uses credentials: 'include'.
  2. Confirm the Hono CORS middleware sets credentials: true.
  3. Confirm origin is the exact frontend origin string.
  4. Confirm the cookie uses SameSite=None; Secure; HttpOnly; Path=/.
  5. Confirm the browser request actually shows a Cookie header on the protected route.
  6. Confirm the browser console has no blocked-cookie warning.
  7. Confirm the API and frontend are not mixing localhost, 127.0.0.1, and multiple ports by accident.

That sequence isolates the failure quickly because each layer has a distinct responsibility. The client must opt in to credentials, the server must allow credentialed CORS, and the cookie itself must be eligible for cross-site storage and transmission.

Practical takeaway

For browser-based session auth across origins, prefer fetch(..., { credentials: 'include' }) on the client, Hono CORS with origin: 'http://your-frontend-origin' and credentials: true, and cookies set with HttpOnly, Secure, SameSite=None, and Path=/. That combination matches browser policy. Anything less usually results in a response that looks successful while the session cookie never persists or never comes back.