Sending Cookies from the Browser
fetch does not send cookies cross-origin unless you ask — the credentials option and what it changes.
fetch(url, { credentials: 'include' })On a same-origin request the browser attaches cookies without being asked. As soon as the request crosses an origin, fetch changes its mind: by default it sends no cookies and ignores any Set-Cookie in the response.
The credentials option
| Value | Behaviour |
|---|---|
omit | Never send cookies, even same-origin. |
same-origin | The default. Cookies on same-origin requests only. |
include | Send cookies cross-origin too — and honour Set-Cookie from the response. |
So a frontend on abc.com calling dfg.com must pass credentials: 'include'. And that is only half the requirement: the server must also answer with Access-Control-Allow-Credentials: true and an exact origin. Miss either side and the browser silently drops the cookie — the request still goes out, just anonymously.
XMLHttpRequest and axios
The older API calls it withCredentials = true. Axios exposes the same flag, and it is per-request or per-instance — a very common cause of "it works in Postman but not in the browser", since Postman has no origin and no cookie policy to enforce.
The login response body matters
Because your JavaScript cannot read an HttpOnly cookie, it has no way to inspect the session. So the login endpoint should return the user object, and you need a GET /api/me for the app to answer "am I still logged in?" after a page reload.
Handling 401 centrally
Wrap fetch once. Every call gets credentials: 'include', and a 401 anywhere in the app clears local state and redirects to login — rather than each component inventing its own handling.
Example
// Same origin — the default already sends cookies
await fetch('/api/orders');
// Cross origin (abc.com → dfg.com) — you MUST opt in
await fetch('https://dfg.com/api/orders', {
credentials: 'include',
});
// Logging in cross-origin: 'include' is also what lets Set-Cookie be stored
const res = await fetch('https://dfg.com/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const { user } = await res.json(); // the cookie itself is invisible to JSWhen to use it
- A React SPA wraps fetch once so that every call carries credentials and a 401 anywhere triggers a single global redirect to the login page.
- A team debugs a login that works in Postman but never persists in the browser, and finds the missing credentials: 'include' on the login call itself.
- An app calls GET /api/me on boot to restore the session after a page reload, because the HttpOnly cookie is invisible to its own JavaScript.
More examples
One API client for the whole app
The custom header is not decoration: a cross-site request cannot add it without a preflight the server can refuse, which is a real second layer under SameSite.
const API = 'https://dfg.com';
export async function api(path, options = {}) {
const res = await fetch(API + path, {
...options,
credentials: 'include', // cookies on every call
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest', // forces a preflight: CSRF defence
...options.headers,
},
});
if (res.status === 401) {
store.clearUser();
window.location.href = '/login?next=' +
encodeURIComponent(location.pathname);
throw new Error('unauthenticated');
}
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.statusText);
return res.status === 204 ? null : res.json();
}
// Usage
const orders = await api('/api/orders');
await api('/api/orders/18', { method: 'DELETE' });Restoring the session on page load (React)
Render a loading state rather than assuming anonymous — flashing the login screen to an already-logged-in user on every refresh is the usual symptom of skipping it.
import { useEffect, useState } from 'react';
export function useSession() {
const [state, setState] = useState({ status: 'loading', user: null });
useEffect(() => {
let cancelled = false;
// The cookie is HttpOnly: the only way to know if we are logged in is to ask.
fetch('https://dfg.com/api/me', { credentials: 'include' })
.then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
.then((d) => !cancelled && setState({ status: 'authenticated', user: d.user }))
.catch(() => !cancelled && setState({ status: 'anonymous', user: null }));
return () => { cancelled = true; };
}, []);
return state; // 'loading' | 'authenticated' | 'anonymous'
}
Discussion