The Frontend with Token Handling

Storing the token in memory, attaching it to every call, and refreshing exactly once when it expires.

The token frontend is longer than the cookie one, and every extra line is doing something the browser used to do for you.

The three jobs

  1. Hold the access token — a module-scoped variable. Not localStorage; an XSS reads that in one line.
  2. Attach it to every request as Authorization: Bearer ….
  3. Refresh on 401, then retry the original request once.

The concurrency trap

A dashboard fires six requests at page load. The token has just expired, so all six return 401, and a naive client fires six refresh calls. With rotation enabled, five of them present an already-used refresh token — which your reuse detection correctly interprets as a theft, and it revokes the entire family. Your own client has just logged the user out.

The fix is one line of state: a shared in-flight promise, so concurrent callers await the same refresh.

Restoring the session on reload

Memory is empty after a refresh of the page, so on boot the app calls /auth/refresh once. Success means the refresh cookie was still valid and you have a new access token; failure means show the login screen. This is what buys back the persistence cookies gave for free.

Multiple tabs

Each tab has its own memory and refreshes independently. A BroadcastChannel lets the first tab to refresh share the result, which avoids simultaneous rotations racing each other.

Example

Example · javascript
let accessToken = null;
let refreshing = null;                    // the shared in-flight refresh

async function refresh() {
  const res = await fetch(API + '/auth/refresh', {
    method: 'POST',
    credentials: 'include',               // sends the HttpOnly rt cookie
  });
  if (!res.ok) { accessToken = null; throw new Error('session_expired'); }
  ({ accessToken } = await res.json());
  return accessToken;
}

const getToken = () =>
  accessToken ?? (refreshing ??= refresh().finally(() => { refreshing = null; }));

When to use it

  • A dashboard loading six widgets at once triggers a single refresh instead of six, avoiding a false reuse-detection lockout.
  • A user refreshes the page and stays signed in because the app calls /auth/refresh on boot rather than persisting the token.
  • Five open tabs share one refreshed token over a BroadcastChannel, so only one rotation happens per expiry.

More examples

web/app.js — the token client in full

getToken refreshes 30 seconds early, which removes most 401-and-retry round trips entirely — the token is renewed before any request is sent with a stale one.

Example · javascript
const API = 'https://dfg.local:8443';

/* ---------------- token state: memory only ---------------- */
let accessToken = null;
let expiresAt = 0;
let refreshing = null;

const channel = 'BroadcastChannel' in window ? new BroadcastChannel('auth') : null;
if (channel) channel.onmessage = (e) => {
  if (e.data?.type === 'token') ({ accessToken, expiresAt } = e.data);
  if (e.data?.type === 'logout') { accessToken = null; expiresAt = 0; showAuth(); }
};

async function refresh() {
  const res = await fetch(API + '/auth/refresh', {
    method: 'POST',
    credentials: 'include',              // the ONLY call that uses the cookie
  });
  if (!res.ok) {
    accessToken = null; expiresAt = 0;
    throw new Error('session_expired');
  }
  const { accessToken: t, expiresIn } = await res.json();
  accessToken = t;
  expiresAt = Date.now() + expiresIn * 1000;
  channel?.postMessage({ type: 'token', accessToken, expiresAt });
  return t;
}

// One shared promise: ten callers, one refresh.
function getToken() {
  if (accessToken && Date.now() < expiresAt - 30_000) return Promise.resolve(accessToken);
  return (refreshing ??= refresh().finally(() => { refreshing = null; }));
}

/* ---------------- the API client ---------------- */
async function api(path, options = {}) {
  const send = (token) => fetch(API + path, {
    ...options,
    // No credentials: these calls carry no cookie at all.
    headers: {
      'Content-Type': 'application/json',
      ...options.headers,
      Authorization: `Bearer ${token}`,
    },
  });

  let res;
  try {
    res = await send(await getToken());
  } catch {
    showAuth();
    throw new Error('unauthenticated');
  }

  if (res.status === 401) {
    // Expired between our check and the server's. Refresh ONCE and retry ONCE.
    try {
      res = await send(await (refreshing ??= refresh().finally(() => { refreshing = null; })));
    } catch {
      showAuth();
      throw new Error('unauthenticated');
    }
    if (res.status === 401) { showAuth(); throw new Error('unauthenticated'); }
  }

  if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? res.statusText);
  return res.status === 204 ? null : res.json();
}

/* ---------------- auth actions ---------------- */
document.querySelector('#login-form').addEventListener('submit', async (e) => {
  e.preventDefault();
  const form = new FormData(e.target);

  const res = await fetch(API + '/auth/login', {
    method: 'POST',
    credentials: 'include',              // needed so the rt cookie is stored
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email: form.get('email'), password: form.get('password') }),
  });

  if (!res.ok) {
    document.querySelector('#auth-error').textContent = 'Wrong email or password.';
    return;
  }

  const { user, accessToken: t, expiresIn } = await res.json();
  accessToken = t;
  expiresAt = Date.now() + expiresIn * 1000;
  channel?.postMessage({ type: 'token', accessToken, expiresAt });
  await showNotes(user);
});

document.querySelector('#logout').addEventListener('click', async () => {
  await fetch(API + '/auth/logout', { method: 'POST', credentials: 'include' });
  accessToken = null; expiresAt = 0;
  channel?.postMessage({ type: 'logout' });
  showAuth();
});

/* ---------------- boot: memory is empty, so refresh ---------------- */
(async function boot() {
  try {
    await getToken();                    // uses the HttpOnly refresh cookie
    const { user } = await api('/api/me');
    await showNotes(user);
  } catch {
    showAuth();
  }
})();

Why the shared promise is not optional

This bug only appears under concurrency with rotation enabled, so it survives development and manual testing and shows up as random logouts in production.

Example · javascript
// ❌ Naive: every caller refreshes for itself
async function apiBad(path) {
  let res = await fetch(API + path, { headers: auth() });
  if (res.status === 401) {
    await refresh();                     // ← six parallel calls = six refreshes
    res = await fetch(API + path, { headers: auth() });
  }
  return res.json();
}

// Page load fires six requests. The token has just expired:
//
//   /api/me      401 → refresh with RT1 → rotates to RT2 ✅
//   /api/notes   401 → refresh with RT1 → ALREADY USED   ❌
//   /api/tags    401 → refresh with RT1 → ALREADY USED   ❌
//   ...
//
// The server sees RT1 replayed five times, concludes the token was stolen,
// and revokes the whole family. Your own client just logged the user out.

// ✅ Correct: one refresh, shared
let refreshing = null;
function sharedRefresh() {
  return (refreshing ??= refresh().finally(() => { refreshing = null; }));
}

// All six callers await the same promise. One rotation, five reuses of the
// RESULT — which is not a reuse of the token at all.

Discussion

  • Be the first to comment on this lesson.