The Frontend on abc.com
About forty lines of JavaScript — and the single option that decides whether any of it works.
The frontend for the cookie version is short, because the browser is doing the credential handling. There is no token to store, attach or refresh. There is exactly one thing you must not forget.
credentials: 'include' on every call
Without it, fetch sends no cookie cross-origin and ignores Set-Cookie in the response. It is needed on the login call (so the cookie is stored) and on every subsequent call (so it is sent). Missing it on login is the more confusing failure: login returns 200, and every call after it returns 401.
You cannot read the cookie
It is HttpOnly, so document.cookie shows nothing. The app therefore cannot ask "am I logged in?" locally — it has to ask the server, with GET /api/me on boot. Render a loading state while that is in flight, or you will flash the login form at users who are already signed in.
Handle 401 in one place
Wrap fetch once. Every call gets credentials: 'include', and a 401 anywhere clears local state and shows the login screen. Scattering that logic across components is how you end up with a half-logged-out UI.
What you get for free
- Refresh the page — still logged in.
- Open a new tab — still logged in.
- Close the browser and come back within 30 minutes — still logged in.
That persistence is the cookie version's real advantage, and it is why the token version needs a refresh call to match it.
Example
const API = 'https://dfg.local:8443';
async function api(path, options = {}) {
const res = await fetch(API + path, {
...options,
credentials: 'include', // ← the whole scheme depends on this
headers: { 'Content-Type': 'application/json', ...options.headers },
});
if (res.status === 401) { showLogin(); throw new Error('unauthenticated'); }
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? res.statusText);
return res.status === 204 ? null : res.json();
}When to use it
- A React app restores the session after a refresh by calling /api/me on mount instead of persisting anything client-side.
- A team consolidates 401 handling into one fetch wrapper, fixing a bug where some screens showed stale data after the session expired.
- A developer confirms in devtools that no Cookie header is being sent, and finds the missing credentials option rather than blaming CORS.
More examples
web/app.js — the complete frontend
The boot function is the whole session-restore mechanism. There is nothing stored client-side to check — the server is the only source of truth about whether you are logged in.
const API = 'https://dfg.local:8443'; // https://dfg.com in production
const $ = (sel) => document.querySelector(sel);
const authSection = $('#auth');
const notesSection = $('#notes');
/* ---------- one API client, one place that handles 401 ---------- */
async function api(path, options = {}) {
const res = await fetch(API + path, {
...options,
credentials: 'include', // send + accept cookies
headers: { 'Content-Type': 'application/json', ...options.headers },
});
if (res.status === 401) { showAuth(); throw new Error('unauthenticated'); }
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? res.statusText);
}
return res.status === 204 ? null : res.json();
}
/* ---------- views ---------- */
function showAuth() {
authSection.hidden = false;
notesSection.hidden = true;
}
async function showNotes(user) {
authSection.hidden = true;
notesSection.hidden = false;
$('#who').textContent = user.email;
await renderNotes();
}
async function renderNotes() {
const { notes } = await api('/api/notes');
$('#note-list').replaceChildren(...notes.map((n) => {
const li = document.createElement('li');
li.textContent = n.text; // textContent, never innerHTML
const del = document.createElement('button');
del.textContent = 'Delete';
del.onclick = async () => {
await api(`/api/notes/${n.id}`, { method: 'DELETE' });
await renderNotes();
};
li.append(' ', del);
return li;
}));
}
/* ---------- auth actions ---------- */
$('#login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = new FormData(e.target);
try {
// credentials:'include' here is what lets the browser STORE the cookie.
const { user } = await api('/auth/login', {
method: 'POST',
body: JSON.stringify({
email: form.get('email'), password: form.get('password'),
}),
});
$('#auth-error').textContent = '';
await showNotes(user);
} catch (err) {
$('#auth-error').textContent = err.message === 'invalid_credentials'
? 'Wrong email or password.' : 'Something went wrong.';
}
});
$('#register').addEventListener('click', async () => {
const form = new FormData($('#login-form'));
try {
const { user } = await api('/auth/register', {
method: 'POST',
body: JSON.stringify({
email: form.get('email'), password: form.get('password'),
}),
});
await showNotes(user);
} catch (err) {
$('#auth-error').textContent = 'Could not create that account.';
}
});
$('#logout').addEventListener('click', async () => {
await api('/auth/logout', { method: 'POST' });
showAuth();
});
$('#note-form').addEventListener('submit', async (e) => {
e.preventDefault();
const text = new FormData(e.target).get('text');
await api('/api/notes', { method: 'POST', body: JSON.stringify({ text }) });
e.target.reset();
await renderNotes();
});
/* ---------- boot: the cookie is invisible, so ASK ---------- */
(async function boot() {
try {
const { user } = await api('/api/me');
await showNotes(user); // already signed in
} catch {
showAuth(); // anonymous
}
})();What the network tab should show
Case (d) is the one that wastes afternoons. Testing in Safari early tells you immediately whether the cross-site cookie approach is viable for your users.
# 1. Login — no preflight (POST + application/json DOES preflight, actually):
OPTIONS /auth/login
Origin: https://abc.local:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
← 204
Access-Control-Allow-Origin: https://abc.local:5173
Access-Control-Allow-Credentials: true
POST /auth/login
Origin: https://abc.local:5173
← 200
Set-Cookie: sid=8f2b…; HttpOnly; Secure; SameSite=None; Partitioned; Path=/
# 2. Every call afterwards — look for THIS request header:
GET /api/notes
Cookie: sid=8f2b… ← present = working
← missing = the browser dropped it
# If it is missing, in order of likelihood:
# a. credentials:'include' not set on the call
# b. the response lacked Access-Control-Allow-Credentials: true
# c. the cookie lacked SameSite=None; Secure
# d. the browser blocks third-party cookies (Safari does by default)
# (d) produces NO console error at all. Check the request header, not the console.
Discussion