---
title: "Hono Loses Session Cookies on Cross-Origin Requests Because `fetch` Never Sends Credentials"
description: "Cookie-based auth fails when browser requests omit credentials or the response omits the correct CORS cookie headers."
url: "/hono-loses-session-cookies-on-cross-origin-requests-because-fetch-never-sends-credentials"
canonical_url: "https://bfzli.com/hono-loses-session-cookies-on-cross-origin-requests-because-fetch-never-sends-credentials"
source_url: "https://bfzli.com/hono-loses-session-cookies-on-cross-origin-requests-because-fetch-never-sends-credentials.md"
type: "article"
updated: "2026-09-13"
date: "2026-09-13"
tags: ["hono", "cookies", "cors", "session", "fetch"]
---

> Markdown copy of https://bfzli.com/hono-loses-session-cookies-on-cross-origin-requests-because-fetch-never-sends-credentials. Append `.md` to any page path on bfzli.com for its markdown twin. Full index: https://bfzli.com/llms.txt

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

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:

- `fetch()` omits cookies unless `credentials` is set to `include` for cross-origin requests.
- `XMLHttpRequest` omits cookies unless `withCredentials = true`.
- A credentialed CORS response must return a non-wildcard `Access-Control-Allow-Origin` that exactly matches the request origin.
- A credentialed CORS response must return `Access-Control-Allow-Credentials: true`.
- Cross-site cookies generally need `SameSite=None; Secure`.
- `Secure` cookies are only stored and sent over HTTPS, except that `localhost` is treated specially by browsers for development in some cases.
- If the cookie `Domain`, `Path`, `Expires`, or `Max-Age` is wrong, the browser may store it but not send it where expected.

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:

- `Access-Control-Allow-Origin: http://localhost:3000`
- `Access-Control-Allow-Credentials: true`

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 `*`.

## Setting the cookie correctly

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:

- `httpOnly: true` to keep JavaScript from reading the cookie.
- `secure: true` so the browser accepts `SameSite=None`.
- `sameSite: 'None'` for cross-site cookie usage.
- `path: '/'` so the cookie is available across the app.
- `maxAge` or `expires` so the session persists as intended.

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:

- Run the frontend and API over HTTPS locally.
- Use a local reverse proxy that terminates TLS.
- Keep development same-site by serving API and frontend from the same origin.
- Use a browser environment where `localhost` special-casing applies, while still testing the real production settings before release.

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:

- `Cookie: session=...` on the outgoing request.
- `Origin: http://localhost:3000` on the cross-origin request.

Inspect the response headers for:

- `Access-Control-Allow-Origin: http://localhost:3000`
- `Access-Control-Allow-Credentials: true`
- `Set-Cookie: session=...; Path=/; HttpOnly; Secure; SameSite=None`

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.
