Skip to content
academia.sh

Lesson 06 / 23

Refresh Tokens

Separating the short-lived access token from the long-lived refresh token: storing the refresh token only as a hash, rotating it on every use, and measuring that presenting a used token again results in family revocation.

Contents

The previous two lessons left a dilemma. In a stateless token, the lifetime determined the revocation window: as the lifetime shortened, the window narrowed, but the user had to log in again frequently. The dilemma is born from the single-token assumption. When two tokens are used, lifetime and ease of use can be tuned separately.

The access token is short-lived and is attached to every request. The refresh token is long-lived, goes to only one endpoint — the refresh endpoint — and gets a fresh access token in return. This distinction alone provides no gain; the gain comes from the long-lived one being recorded on the server and replaced on every use.

Two Tokens, Two Tasks

The access token goes out on every request, and so it passes through every intermediate stop — the reverse proxy, the log, the error-tracing record. The refresh token goes only to the refresh endpoint; the path it takes is short, and it appears nowhere outside that path.

The second one is stateful. The access token is verified by its signature and no record is consulted; the refresh token, on the other hand, corresponds to a row on the server. This is not abandoning the previous lesson’s statelessness: the record read happens on the refresh request, which occurs about once a minute, not on the resource request, which occurs hundreds of times a second.

Where the token is placed on the client side is a separate decision, addressed in the Application Architecture course’s Authentication in the Browser lesson; this lesson builds the server side.

Storing the Refresh Token

The refresh token is not stored in plain form on the server. The stored value is the token’s hash; the token itself lives only on the client. The token consists of two parts: an id used for lookup and a secret used for verification. The id part makes it possible to reach the row directly, without trying every row one by one.

// storage.mjs — the form the refresh token takes when it lives on the server
// Usage: node storage.mjs
import { randomBytes, createHash } from "node:crypto";
import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync(":memory:");
db.exec(`CREATE TABLE refresh (id TEXT PRIMARY KEY, family TEXT NOT NULL, hash TEXT NOT NULL,
                                issuedAt INTEGER NOT NULL, usedAt INTEGER)`);

const hash = (secret) => createHash("sha256").update(secret).digest("base64url");

const id = randomBytes(9).toString("base64url");
const secret = randomBytes(32).toString("base64url");
const givenToClient = `${id}.${secret}`;
db.prepare("INSERT INTO refresh (id, family, hash, issuedAt) VALUES (?, ?, ?, ?)")
  .run(id, "A-1", hash(secret), 1785000000000);

const row = db.prepare("SELECT * FROM refresh WHERE id = ?").get(id);
console.log("given to client   :", givenToClient);
console.log("row in the table  :", JSON.stringify(row));
console.log("secret in the row :", JSON.stringify(row).includes(secret));
console.log("presented value verifiable:", hash(secret) === row.hash);
given to client   : TTmjUYLeJBWW.KceoD5WLvBFMGKp_252da785lGs2nMAvS7fxpUWa3Z8
row in the table  : {"id":"TTmjUYLeJBWW","family":"A-1","hash":"Y-axdik0-rxrqG-L1fsDqyNx9DJ6-qQlylepGEhKsys","issuedAt":1785000000000,"usedAt":null}
secret in the row : false
presented value verifiable: true

The id and secret change on every run. The row holds no secret; yet the value the client presents can still be verified. Reading the database does not yield valid refresh tokens.

The single-pass digest used here is not sufficient for storing passwords. The difference lies in the predictability of the input: the secret is generated from 32 bytes of random data, and finding it by trial is not a realistic prospect. Because this condition does not hold for passwords, a separate method is required; that topic is addressed on its own.

The Refresh Endpoint

The server below issues both tokens, builds the refresh endpoint, and can also produce the without-rotation setup with a flag. Refresh tokens belong to a family: the family opened at one login covers every refresh token descended from that login.

// refresh.mjs — short-lived access token + long-lived refresh token
// Usage: node refresh.mjs <port> [without-rotation]
//   without-rotation: the same refresh token stays valid across refreshes (counter-example)
// Env: ACCESS_MS (access token lifetime, default 3000)
import { createServer } from "node:http";
import { createHmac, randomBytes, timingSafeEqual, createHash } from "node:crypto";
import { DatabaseSync } from "node:sqlite";

const PORT = Number(process.argv[2] ?? 8541);
const WITHOUT_ROTATION = process.argv.includes("without-rotation");
const ACCESS_MS = Number(process.env.ACCESS_MS ?? 3000);
const FAMILY_LIFETIME_MS = 30 * 24 * 3_600_000;              // the family's absolute lifetime

const KEY = randomBytes(32);
const ACCOUNTS = new Map([["clara.diaz", { password: "café-books-1876", code: "U-1001", role: "member" }]]);

const db = new DatabaseSync(":memory:");
db.exec(`
  CREATE TABLE family (code TEXT PRIMARY KEY, member TEXT NOT NULL, opened INTEGER NOT NULL,
                       revoked INTEGER NOT NULL DEFAULT 0, revocationReason TEXT);
  CREATE TABLE refresh (id TEXT PRIMARY KEY, family TEXT NOT NULL, hash TEXT NOT NULL,
                         issuedAt INTEGER NOT NULL, usedAt INTEGER);
`);

// Refresh token: "<id>.<secret>". Only the secret's hash lives on the server.
const hash = (secret) => createHash("sha256").update(secret).digest("base64url");
const issueRefresh = (family) => {
  const id = randomBytes(9).toString("base64url");
  const secret = randomBytes(32).toString("base64url");
  db.prepare("INSERT INTO refresh (id, family, hash, issuedAt) VALUES (?, ?, ?, ?)")
    .run(id, family, hash(secret), Date.now());
  return `${id}.${secret}`;
};

const b64 = (v) => Buffer.from(v).toString("base64url");
const issueAccess = (principal) => {
  const body = b64(JSON.stringify({ ...principal, exp: Date.now() + ACCESS_MS }));
  return `${body}.${createHmac("sha256", KEY).update(body).digest("base64url")}`;
};
const verifyAccess = (token) => {
  const [body, signature] = (token ?? "").split(".");
  if (!body || !signature) return null;
  const expected = createHmac("sha256", KEY).update(body).digest("base64url");
  const a = Buffer.from(expected, "utf8"), b = Buffer.from(signature, "utf8");
  if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
  const claims = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
  return Date.now() < claims.exp ? claims : null;
};

const revokeFamily = (code, reason) =>
  db.prepare("UPDATE family SET revoked = 1, revocationReason = ? WHERE code = ?").run(reason, code);

// Evaluating the refresh token: four separate rejection reasons are distinguished.
const resolveRefresh = (value) => {
  const [id, secret] = (value ?? "").split(".");
  const row = id ? db.prepare("SELECT * FROM refresh WHERE id = ?").get(id) : undefined;
  if (!row) return { error: "unrecognized" };

  const a = Buffer.from(hash(secret ?? ""), "utf8"), b = Buffer.from(row.hash, "utf8");
  if (a.length !== b.length || !timingSafeEqual(a, b)) return { error: "hash" };

  const family = db.prepare("SELECT * FROM family WHERE code = ?").get(row.family);
  if (family.revoked) return { error: "family revoked", family };
  if (Date.now() - family.opened > FAMILY_LIFETIME_MS) return { error: "family lifetime expired", family };

  // A used token was presented again: the sign that a copy is circulating.
  if (row.usedAt !== null && !WITHOUT_ROTATION) {
    revokeFamily(family.code, "reuse detected");
    return { error: "reuse", family };
  }
  return { row, family };
};

const readBody = (request) =>
  new Promise((resolve) => { let v = ""; request.on("data", (p) => (v += p)); request.on("end", () => resolve(v)); });

createServer(async (request, response) => {
  response.sendDate = false;
  const path = request.url.split("?")[0];
  const json = (code, body) => {
    response.writeHead(code, { "content-type": "application/json; charset=utf-8" });
    response.end(JSON.stringify(body));
  };

  if (request.method === "POST" && path === "/login") {
    const { username, password } = JSON.parse((await readBody(request)) || "{}");
    const account = ACCOUNTS.get(username ?? "");
    if (!account || password !== account.password) return json(401, { error: "could not authenticate" });
    const family = randomBytes(6).toString("base64url");
    db.prepare("INSERT INTO family (code, member, opened) VALUES (?, ?, ?)").run(family, account.code, Date.now());
    return json(200, { access: issueAccess({ code: account.code, role: account.role }), refresh: issueRefresh(family) });
  }

  if (request.method === "POST" && path === "/refresh") {
    const { refresh } = JSON.parse((await readBody(request)) || "{}");
    const result = resolveRefresh(refresh);
    if (result.error) return json(401, { error: result.error });

    const member = db.prepare("SELECT member FROM family WHERE code = ?").get(result.family.code).member;
    const body = { access: issueAccess({ code: member, role: "member" }) };
    if (WITHOUT_ROTATION) {
      body.refresh = refresh;                       // the same token stays valid
    } else {
      db.prepare("UPDATE refresh SET usedAt = ? WHERE id = ?").run(Date.now(), result.row.id);
      body.refresh = issueRefresh(result.family.code);   // rotation: a new token
    }
    return json(200, body);
  }

  if (path === "/me") {
    const bearer = (request.headers.authorization ?? "").startsWith("Bearer ")
      ? request.headers.authorization.slice(7) : null;
    const claims = verifyAccess(bearer);
    return claims ? json(200, { code: claims.code }) : json(401, { error: "access token invalid" });
  }

  if (path === "/diagnostics") {
    return json(200, {
      families: db.prepare("SELECT code, revoked, revocationReason FROM family").all(),
      refreshCount: db.prepare("SELECT COUNT(*) n FROM refresh").get().n,
      usedCount: db.prepare("SELECT COUNT(*) n FROM refresh WHERE usedAt IS NOT NULL").get().n,
    });
  }
  json(404, { error: "no such resource" });
}).listen(PORT, "127.0.0.1", () => console.log(`refresh 127.0.0.1:${PORT} without-rotation=${WITHOUT_ROTATION}`));

The Access Token’s Lifetime

When the access token’s lifetime is pulled down to three seconds, the flow becomes observable: a 401 is received once the token expires, a fresh token is obtained from the refresh endpoint, and the request works again.

// access-lifetime.mjs — the access token's lifetime and refreshing it back to full strength
// Usage: while ACCESS_MS=3000 node refresh.mjs 8543 &  is running,  node access-lifetime.mjs 8543
const PORT = Number(process.argv[2] ?? 8543);
const BASE = `http://127.0.0.1:${PORT}`;
const send = async (path, body) =>
  (await fetch(BASE + path, {
    method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
  })).json();
const status = async (access) =>
  (await fetch(`${BASE}/me`, { headers: { authorization: `Bearer ${access}` } })).status;

const login = await send("/login", { username: "clara.diaz", password: "café-books-1876" });
const start = Date.now();
console.log("/me at login             :", await status(login.access));
await new Promise((c) => setTimeout(c, 3200));
console.log("/me after 3.2s           :", await status(login.access),
  `(access lifetime 3s, elapsed ${((Date.now() - start) / 1000).toFixed(1)}s)`);

const fresh = await send("/refresh", { refresh: login.refresh });
console.log("/me after refreshing     :", await status(fresh.access));
console.log("with old access token    :", await status(login.access));
ACCESS_MS=3000 node refresh.mjs 8543 > /dev/null 2>&1 & S=$!
sleep 1.2
node access-lifetime.mjs 8543
kill $S
/me at login             : 200
/me after 3.2s           : 401 (access lifetime 3s, elapsed 3.2s)
/me after refreshing     : 200
with old access token    : 401

The last line matters: refreshing does not make the old access token valid again. Every access token is bound to its own exp value, and once it expires it does not come back. All refreshing does is issue a new token.

Rotated Use

Rotation is replacing the refresh token on every use: the server marks the old row as used and issues a new token. The consequence of this is that every refresh token is single-use.

The measurable gain of being single-use is that a used token, when presented again, carries a signal. A normal client does not do this, because it received a new token the moment it used the one it had. A second presentation of the same token says that a copy of that token is circulating.

// rotation.mjs — measuring rotated use and reuse detection
// Usage: ACCESS_MS=3000 node refresh.mjs 8541 &                    (rotation on)
//        ACCESS_MS=3000 node refresh.mjs 8542 without-rotation &   (rotation off)
//        node rotation.mjs 8541   /   node rotation.mjs 8542
const PORT = Number(process.argv[2]);
const BASE = `http://127.0.0.1:${PORT}`;
const send = async (path, body) =>
  (await fetch(BASE + path, {
    method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
  })).json();

const login = await send("/login", { username: "clara.diaz", password: "café-books-1876" });
const firstRefresh = login.refresh;

// Refresh three times; each time use the refresh token currently held.
let held = firstRefresh;
for (let i = 1; i <= 3; i++) {
  const r = await send("/refresh", { refresh: held });
  console.log(`refresh ${i}:`, r.error ? `reject (${r.error})` : "accept",
    "| new refresh token:", !r.error && r.refresh !== held);
  if (!r.error) held = r.refresh;
}

// The first refresh token is presented again (the case where a copy is circulating).
const again = await send("/refresh", { refresh: firstRefresh });
console.log("first token again     :", again.error ? `reject (${again.error})` : "accept");

// Retried with the most recently valid token.
const after = await send("/refresh", { refresh: held });
console.log("with the current token:", after.error ? `reject (${after.error})` : "accept");
console.log("diagnostics:", JSON.stringify(await (await fetch(`${BASE}/diagnostics`)).json()));
ACCESS_MS=3000 node refresh.mjs 8541 > /dev/null 2>&1 & ROTATED=$!
ACCESS_MS=3000 node refresh.mjs 8542 without-rotation > /dev/null 2>&1 & UNROTATED=$!
sleep 1.2
echo "— rotated use —"
node rotation.mjs 8541
echo "— no rotation —"
node rotation.mjs 8542
kill $ROTATED $UNROTATED
— rotated use —
refresh 1: accept | new refresh token: true
refresh 2: accept | new refresh token: true
refresh 3: accept | new refresh token: true
first token again     : reject (reuse)
with the current token: reject (family revoked)
diagnostics: {"families":[{"code":"KxzE6xW5","revoked":1,"revocationReason":"reuse detected"}],"refreshCount":4,"usedCount":3}
— no rotation —
refresh 1: accept | new refresh token: false
refresh 2: accept | new refresh token: false
refresh 3: accept | new refresh token: false
first token again     : accept
with the current token: accept
diagnostics: {"families":[{"code":"2QSKkxdS","revoked":0,"revocationReason":null}],"refreshCount":1,"usedCount":0}

The family codes change on every run. The difference between the two blocks tells the whole story of what rotation buys on its own.

In the rotated setup, after three refreshes the table has four rows, and three of them are used. When the first token is presented again, the server detects it: the row’s usedAt field is filled in. The decision’s consequence is not just rejecting that request — the family is revoked, and as the next line shows, even the current token stops working. The reason is that which side is the copy cannot be known: both the one presenting the old token again and the one holding the current token belong to the same family. The safe decision is to drop both and require a new login.

In the without-rotation setup, the same token was used four times, all four were accepted, and the table has a single row; usedCount is zero. There is no trace to show whether a copy is circulating. A condition the server cannot detect is a condition the server cannot respond to.

False Alarms and the Family’s Lifetime

Detection has one flaw: two concurrent requests with the same refresh token produce the same signal. If two tabs of the application, or two requests, get a 401 at the same moment and both go to refresh with the same token, the second one ends up presenting a used token, and the family is revoked for nothing. The solution sits on the client side and is built under the name single flight in the Application Architecture course’s Session Renewal lesson: only one refresh request goes out at a time, and the others wait for its result. On the server side, a small grace window can also be granted — the same request arriving within a few seconds of a rotation is answered with the same new token.

The family also has an absolute lifetime. Because rotation issues a new token on every use, a family in regular use never ends on its own. FAMILY_LIFETIME_MS bounds this: a certain time after the first login, the family closes regardless of how much it is used, and a new login is required. The family record is also the counterpart of logout: a logout request revokes the family, so every refresh token descended from that login drops in a single operation.

The revocation window thus splits into two different durations. For the access token, the window is its short lifetime — three seconds, fifteen minutes, whatever was chosen. For the refresh token, the window is zero, because the decision is a row in the database.

Summary

  • The access token is short-lived and attached to every request; the refresh token is long-lived and goes only to the refresh endpoint.
  • The refresh token is stored on the server as a hash; the token consists of an id used for lookup and a secret used for verification, and reading the database does not yield a valid token.
  • Rotation replaces the refresh token on every use and makes it single-use; a used token being presented again is a signal that a copy is circulating.
  • On detection, the family is revoked; because which side is the copy cannot be known, the current token drops too.
  • In a without-rotation setup, the same token is used without limit, and no trace is left to show reuse.
  • Concurrent refreshing can produce a false alarm; single flight on the client and a short grace window on the server address it. The family’s absolute lifetime and revocation at logout determine the session’s end.

Next Step

Up to this point, every step of authentication has taken place on a single side: the user gave their password to the loan service, and the loan service issued the token and verified it itself too. This arrangement does not work when the library’s district branches share a common member registry, or when a member wants to expose their loan history to a third-party reading app: the password would have to be given to that application. The next lesson builds the authorization delegation protocol that solves this problem — the resource owner gives their password only to their own identity provider, and the application gets a limited authorization in return. The authorization code flow, the state parameter, and the verifier will be run end to end with your own local servers.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close