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 headerHttpOnly cookie session
Sent byyour code, explicitlythe browser, automatically
Reachable by JavaScriptyes, which is why XSS is fatal hereno, the script cannot read it
Works cross-siteyes, if CORS allows the headeronly with SameSite=None; Secure
CSRF exposurelow: a form post cannot set the headerhigh, unless defences are added
Logoutdrop the token client-side; it stays valid until it expiresserver clears the cookie, effective immediately
Logout everywhereneeds a server-side denylist or short lifetimesdelete 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=Lax blocks 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-Token or Content-Type: application/json forces a preflight, which a cross-site form cannot pass.
  • Store the access token in a variable in memory. localStorage survives 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.

Same-origin policy, CORS and credentials Building an API client layer

Last refreshed 2026-09-18.