Skip to content
academia.sh

Lesson 21 / 22

Session Renewal

Refreshing a short-lived token without it reaching the user, gathering concurrent authorization errors into a single renewal, the refresh token's rotation and reuse detection, and sign-out taking effect everywhere.

Contents

The previous lesson presented the token’s short lifetime as a defense. The cost of that short lifetime is clear: the session dropping every fifteen minutes. If the time runs out while the observer is filling out the measurement form, the submission comes back with a 401, and everything they wrote is put at risk.

This lesson builds the arrangement that keeps the short lifetime without paying that cost: refreshing without the user noticing, gathering multiple simultaneously dropped requests into a single refresh, protecting the refresh channel itself, and the session closing everywhere at the same time.

Renewal’s Three Triggers

Reactive renewal happens when a 401 response is received. The request layer sees the response, refreshes the token, and repeats the request once. It is the simplest and most resilient path: it relies on the server’s decision, not on the client’s clock.

Proactive renewal happens shortly before the time runs out. The expiry check from the previous lesson serves this: the token’s exp field is read, and it is refreshed early, leaving an allowance. Its payoff is that the user does not wait for a request to go out twice. It is not sufficient on its own — the client’s clock might be wrong, the server might have revoked the token early — so it does not replace reactive renewal, it works alongside it.

Silent session establishment at startup happens when the page refreshes. In an arrangement that keeps the access token in memory, nothing is left on hand once the page refreshes; the application tries the refresh channel once at startup. If it succeeds, the session continues where it left off; if it fails, the session is considered closed. The unknown identity state mentioned in the previous lesson applies for exactly the duration of this attempt.

Single Flight

When a screen opens, several requests usually go out at once: the station list, measurement history, a summary, alerts. If the token has expired, all four get a 401. If each one starts its own renewal, four renewal requests go to the server — and if the token rotates, three of them arrive with an invalidated token, and the session closes.

The solution is the single flight pattern: the promise produced when renewal starts is stored, and everyone requesting a renewal at the same time waits on that same promise.

// session-renewal.mjs — silent renewal, single flight, and reuse detection
import http from "node:http";

// --- Server -------------------------------------------------------------------
let version = 1;
let currentAccess = "e1", currentRefresh = "y1";
let sessionOpen = true, renewalCount = 0;
const used = new Set();

const readBody = (request) => new Promise((resolve) => {
  let m = ""; request.on("data", (p) => (m += p)); request.on("end", () => resolve(m ? JSON.parse(m) : {}));
});

const server = http.createServer(async (request, response) => {
  const path = new URL(request.url, "http://127.0.0.1").pathname;
  const send = (status, body) => {
    response.writeHead(status, { "content-type": "application/json" });
    response.end(JSON.stringify(body));
  };

  if (path === "/measurements") {
    if (!sessionOpen || request.headers.authorization !== `Bearer ${currentAccess}`)
      return send(401, { code: "no_identity" });
    return send(200, { record: "o-114", version });
  }

  if (path === "/session/refresh") {
    const { refresh } = await readBody(request);
    if (used.has(refresh)) {                  // same token twice: revoke the family
      sessionOpen = false;
      return send(401, { code: "reuse_detected" });
    }
    if (!sessionOpen || refresh !== currentRefresh)
      return send(401, { code: "invalid_refresh" });
    used.add(refresh);
    version += 1; renewalCount += 1;
    currentAccess = `e${version}`; currentRefresh = `y${version}`;
    return send(200, { access: currentAccess, refresh: currentRefresh });
  }

  if (path === "/session/close") { sessionOpen = false; return send(204, {}); }
  send(404, { code: "not_found" });
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const BASE = `http://127.0.0.1:${server.address().port}`;

// --- Client ---------------------------------------------------------------------
const session = { access: "e0", refresh: "y1", open: true };   // access deliberately stale
let inFlight = null;

function renew(usedAccess) {
  // Someone else already renewed: this request is retried directly with the new token.
  if (usedAccess !== session.access) return Promise.resolve(true);
  if (inFlight) return inFlight;

  inFlight = (async () => {
    const response = await fetch(`${BASE}/session/refresh`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ refresh: session.refresh }),
    });
    if (!response.ok) { session.open = false; return false; }
    const body = await response.json();
    session.access = body.access;
    session.refresh = body.refresh;           // rotation: the old token is no longer valid
    return true;
  })();
  inFlight.finally(() => { inFlight = null; });
  return inFlight;
}

async function request(name) {
  if (!session.open) return `${name}: session closed`;
  const usedAccess = session.access;
  let response = await fetch(`${BASE}/measurements`,
    { headers: { authorization: `Bearer ${usedAccess}` } });
  if (response.status !== 401) return `${name}: ${response.status}`;

  const renewed = await renew(usedAccess);
  if (!renewed) return `${name}: session dropped`;

  response = await fetch(`${BASE}/measurements`,
    { headers: { authorization: `Bearer ${session.access}` } });
  const body = await response.json();
  return `${name}: ${response.status} version=${body.version ?? "-"}`;
}

// --- 1. Four requests dropping at the same instant ---------------------------
const results = await Promise.all(["station", "history", "summary", "alerts"].map(request));
results.forEach((s) => console.log("  " + s));
console.log("renewal count on the server:", renewalCount);

// --- 2. The old refresh token used a second time -----------------------------
const old = "y1";
const y = await fetch(`${BASE}/session/refresh`, {
  method: "POST", headers: { "content-type": "application/json" },
  body: JSON.stringify({ refresh: old }),
});
console.log("\nold refresh token →", y.status, JSON.stringify(await y.json()));

// --- 3. After the family has been revoked -------------------------------------
session.open = true;                           // the client does not know yet
console.log("request after revocation   →", await request("history"));
console.log("client's session state     :", session.open ? "open" : "closed");

server.close();
  station: 200 version=2
  history: 200 version=2
  summary: 200 version=2
  alerts: 200 version=2
renewal count on the server: 1

old refresh token → 401 {"code":"reuse_detected"}
request after revocation   → history: session dropped
client's session state     : closed

Reading the Output

All four requests returned successfully, and the renewal counter on the server showed one. Four separate call sites shared a single renewal; none of them knows the others exist.

Two guards make the gathering work, and both are required. The first is the in-flight promise: a second request arriving while a renewal is underway does not start a new one, it joins the one in progress. The second is comparing the token that was used: a request that notices the 401 late does not request a renewal at all if one has already finished — it is retried directly with the new token. With only the first guard in place, a 401 arriving right after a renewal finishes would start an unnecessary second renewal.

The value version=2 shows that all four requests were answered with the token from after the renewal. The retry has to read the token that changed during the renewal; using the stale value captured at the start of the request would have dropped the retry into a 401 too.

Rotation and Reuse Detection

The refresh token is long-lived, and that makes it the most valuable part of the session. Two mechanisms narrow the risk.

Rotation: on every renewal, not just the access token changes but the refresh token too. One that is already been used is never accepted again. So a refresh token’s usable lifetime lasts only until the next renewal.

Reuse detection: if a used refresh token arrives a second time, the server does not treat that as a single client’s mistake. There are two possibilities — either a copy is circulating, or the renewal response never reached the client before the connection dropped. Since there is no way to distinguish the two, the server picks the safe side and revokes every token tied to that session. The output’s second block shows this: when y1 was sent a second time, the response was reuse_detected.

The third block shows the consequence of the revocation. The client did not know the session had closed and sent its request; it got a 401, tried to renew, that was rejected too, and the client marked the session as closed. So what tells the client the session has ended is the renewal failing.

This mechanism has a side effect: a renewal response missed because of a network drop also brings the session down. This is an accepted trade-off; the alternative is treating a used token as valid for a while longer, which reduces the value of the detection.

Loop and Queue Traps

Renewal code is exposed to two traps, and both show up in production.

An infinite loop. If the renewal request itself returns a 401, and that request also goes through the same renewal mechanism, the loop never closes. The rule: the renewal request never, under any condition, goes through the renewal mechanism, and is never retried on its own 401. In the code above, the renewal call does not use the request function; it is sent directly.

A single-retry limit. If the request, retried after a renewal, gets a 401 again, a second renewal is not attempted. This signals that the user really does not have authorization for that resource; mistaking an authorization error for an identity error and continuing to renew wears down the session for nothing.

There is also a queueing decision. New requests arriving while a renewal is underway can be handled two ways: sent with the old token, getting a 401 and joining the queue, or not sent at all until the renewal finishes. The second produces no unnecessary requests, but requires the application to route every request through a gate aware of the identity state. The code above implements the first; all four requests went out, all four got a 401, and all four were gathered into a single renewal.

The Sign-Out Flow

Sign-out consists of three steps, and all three are required.

Invalidation on the server. In a cookie-based arrangement, the session record is deleted and the cookie is rewritten with an empty value and a past expiry date. In a token-based arrangement, the refresh token is revoked. Cleaning up only the client side is not a sign-out; a copy of the token would still remain valid.

Clearing the client’s state. The in-memory token, the identity state, and user-specific data in the cache are cleared. The last of these is frequently skipped: if the measurement history stays in the cache, a second observer signing in on the same browser sees the previous user’s data for a moment. The normalized store from the Data Access topic is the target of this cleanup.

Cross-tab synchronization. The same user might have the application open in three tabs. If a sign-out in one tab is not announced to the others, those tabs keep behaving as if a session were open and get an unexpected 401 on their first request. The announcement is made either with message passing between tabs of the same origin, or by watching for a change in shared storage; both were introduced in the Browser and the Web Platform course. The same mechanism works in reverse too: a session opened in one tab is treated as open in the others.

Sign-out’s counterpart on the user’s side also needs consideration. An unfinished form’s content is discarded on sign-out; asking the user about this is better than discarding it silently. On a forced sign-out — when the session drops — the address the user was on is saved while they are redirected to the login screen, and they are returned there after signing in.

Summary

  • Renewal has three triggers: reactive after a 401, proactive before the time runs out, and silent session establishment at page startup. Proactive renewal does not replace reactive renewal.
  • Concurrent authorization errors are gathered into a single-flight renewal; the in-flight promise and comparing the token that was used are both required together.
  • A retried request must read the token that changed during the renewal; the value captured at the start of the request is no longer valid.
  • The refresh token rotates on every use; a used token arriving a second time results in the whole session being revoked.
  • The renewal request never goes through the renewal mechanism, and a retried request that gets a second 401 is not renewed again.
  • Sign-out requires all three steps together: invalidation on the server, clearing the client’s state and user-specific cache, and cross-tab synchronization.

Next Step

The identity flow is complete: the observer is recognized, their session carries on by itself, and sign-out takes effect everywhere. But the recognized observer also has a language. Every piece of text produced throughout this course — error messages, the “session dropped” notice, empty-result explanations, date and number formats — was written for a single language. For an observer watching the station from Norway, “−4.2” is not the right format, “2 fields need correction” is not the right sentence, and even the interface’s flow direction might differ. The final lesson builds that adaptation.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close