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:
- Whether the outgoing request is allowed to include cookies.
- 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 unlesscredentialsis set toincludefor cross-origin requests.XMLHttpRequestomits cookies unlesswithCredentials = true.- A credentialed CORS response must return a non-wildcard
Access-Control-Allow-Originthat exactly matches the request origin. - A credentialed CORS response must return
Access-Control-Allow-Credentials: true. - Cross-site cookies generally need
SameSite=None; Secure. Securecookies are only stored and sent over HTTPS, except thatlocalhostis treated specially by browsers for development in some cases.- If the cookie
Domain,Path,Expires, orMax-Ageis 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:
tsawait 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:
tsawait 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.
tsawait fetch('http://localhost:8787/api/session', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ email, password }), })
And for reads:
tsawait fetch('http://localhost:8787/api/me', { credentials: 'include', })
If you use a wrapper, preserve the option there as well:
tsexport 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.
tsimport 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:
bashnpm install hono
Configure CORS with the exact frontend origin, not *:
tsimport { 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:3000Access-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.
tsconst 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:
tsimport { 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: trueto keep JavaScript from reading the cookie.secure: trueso the browser acceptsSameSite=None.sameSite: 'None'for cross-site cookie usage.path: '/'so the cookie is available across the app.maxAgeorexpiresso 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
localhostspecial-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:3000on the cross-origin request.
Inspect the response headers for:
Access-Control-Allow-Origin: http://localhost:3000Access-Control-Allow-Credentials: trueSet-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:
tsconst 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:
tsconst 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.
tsimport { 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:
tsawait 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:
- Confirm the frontend request uses
credentials: 'include'. - Confirm the Hono CORS middleware sets
credentials: true. - Confirm
originis the exact frontend origin string. - Confirm the cookie uses
SameSite=None; Secure; HttpOnly; Path=/. - Confirm the browser request actually shows a
Cookieheader on the protected route. - Confirm the browser console has no blocked-cookie warning.
- 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.