Authentication, tokens and CSRF
Bearer tokens against cookie sessions, a refresh flow that does not stampede, and the request-forgery defences that go with each storage choice.
Bearer token or cookie session
| Bearer token in a header | HttpOnly cookie session | |
|---|---|---|
| Sent by | your code, explicitly | the browser, automatically |
| Reachable by JavaScript | yes, which is why XSS is fatal here | no, the script cannot read it |
| Works cross-site | yes, if CORS allows the header | only with SameSite=None; Secure |
| CSRF exposure | low: a form post cannot set the header | high, unless defences are added |
| Logout | drop the token client-side; it stays valid until it expires | server clears the cookie, effective immediately |
| Logout everywhere | needs a server-side denylist or short lifetimes | delete the session record |
The rule that decides most cases: a credential that JavaScript can read is a credential that any injected script can steal. Very short access tokens in memory plus a refresh cookie is the usual compromise, because the stolen token expires before it is useful.
Refresh without a stampede
When the access token expires, every in-flight request gets a 401 at roughly the same moment. If each one triggers its own refresh, you send a burst of refreshes and the refresh token is rotated out from under the losers.
let refreshPromise = null;
function refreshOnce() {
if (!refreshPromise) {
refreshPromise = fetch('/auth/refresh', { method: 'POST', credentials: 'include' })
.then(res => {
if (!res.ok) throw new Error('refresh failed');
return res.json();
})
.finally(() => { refreshPromise = null; }); // clear for the next expiry
}
return refreshPromise; // everyone shares this one
}
async function authed(url, init = {}, retried = false) {
const res = await fetch(url, withAuth(init));
if (res.status !== 401 || retried) return res;
const { access_token } = await refreshOnce();
return authed(url, init, true); // replay exactly once
}
function withAuth(init, token = session.accessToken) {
const headers = Object.assign({}, init.headers, { Authorization: 'Bearer ' + token });
return Object.assign({}, init, { headers });
}- One shared promise is the whole trick. Ten callers, one refresh request, ten retries with the new token.
- Retry the failed request exactly once. A second 401 after a successful refresh is a real authorisation problem, not an expiry, and looping will not fix it.
- Replay only requests that are safe to repeat, or the user's write happens twice. Store the body and re-send it only for idempotent methods, or attach an idempotency key.
- If the refresh itself fails, clear the session and route to login once, from a single place. Ten concurrent redirects to the login page is a common and very visible bug.
CSRF and where to keep the token
function csrfToken() {
const match = document.cookie.match(/(?:^|; )csrf=([^;]*)/);
return match ? decodeURIComponent(match[1]) : '';
}
// double-submit: the cookie is readable, and the same value goes back in a header
await fetch('/api/tasks', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken()
},
body: JSON.stringify({ title: 'Ship it' })
});SameSite=Laxblocks the cross-site POST that a forged form would send, and it costs nothing when every intent is a top-level navigation. It is the first defence, not the whole one.- A forged form can only send simple requests. Requiring a custom header such as
X-CSRF-TokenorContent-Type: application/jsonforces a preflight, which a cross-site form cannot pass. - Store the access token in a variable in memory.
localStoragesurvives a reload but is readable by any script on the page, so one XSS becomes a permanent account takeover. - Never mix the two schemes for the same endpoint: an endpoint that accepts both a cookie and a bearer header can be attacked through the weaker one.
💡
Authentication answers who the caller is; CSRF defences answer whether this caller intended to make this request. A cookie session needs both because the browser attaches the cookie to a request the user never composed.
FAQ
Where should I store an access token?
In a variable in memory for the lifetime of the page, with a refresh token in an
HttpOnly cookie. Anything in localStorage or a readable cookie is available to any injected script, and the token stays valid until it expires.Why do I get a 401 loop after logging in?
A request is retried after a refresh that did not actually produce a usable token, usually because the cookie was not sent, the response shape changed, or the retry has no guard. Retry once, then treat the second 401 as a hard authorisation failure.
Related
Same-origin policy, CORS and credentials Building an API client layer
Last refreshed 2026-09-18.