---
title: 'Refresh Tokens'
source: 'https://academia.sh/en/courses/authentication-and-authorization/refresh-tokens'
course: 'Authentication and Authorization'
language: en
updated: '2026-08-19T05:19:32+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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.

```js
// 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));
```

```bash
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.

```js
// 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()));
```

```bash
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.
