---
title: "Recipe: Single-Flight Refresh-Token Rotation"
canonical: "https://aifitnessapi.com/cookbook/refresh-rotation"
cluster: "Cookbook"
primary_query: "oauth refresh token rotation code example"
last_reviewed: "2026-08-12"
description: "A dependency-free, tested token client: one refresh per user, the rotated token persisted atomically, one retry on 401, a dead grant on invalid_grant."
publisher: "AIFitnessAPI — independent, not sponsored"
cite_as: "\"Recipe: Single-Flight Refresh-Token Rotation\", AIFitnessAPI, https://aifitnessapi.com/cookbook/refresh-rotation"
---

# Recipe: Single-Flight Refresh-Token Rotation

> Refresh-token rotation breaks integrations in four predictable ways: concurrent refreshes race each other into invalid_grant, the returned refresh token is not persisted, a 401 retry loop hammers the provider, and a dead grant gets retried forever. This recipe is a small JavaScript token client that closes all four — refresh is single-flight per user, both tokens are written in one atomic save that is awaited before the promise resolves, a 401 buys exactly one refresh and one retry, and invalid_grant marks the grant dead instead of retrying. The store, the clock and fetch are all injected, so it runs in tests without a network. Copy it, swap the store for your database, and keep the tests.

- Canonical: https://aifitnessapi.com/cookbook/refresh-rotation
- Last reviewed: 2026-08-12
- Publisher: AIFitnessAPI (https://aifitnessapi.com) — independent, not sponsored
- Cite as: "Recipe: Single-Flight Refresh-Token Rotation", AIFitnessAPI, https://aifitnessapi.com/cookbook/refresh-rotation

---

Rotation is the failure mode that takes out every user at once. Your refresh loop runs, the provider hands back a new refresh token, your code keeps the old one, and from that moment nothing can mint a working access token for anybody. The status page says the provider is fine. It usually is.

## The four things the code has to get right

The full triage lives in [refresh token not working](/fix/refresh-token-not-working), and the provider-specific version — including the athlete-deauthorization signal — is in [Strava API 401 Unauthorized](/fix/strava-api-401-unauthorized). The compressed version, because the code is the point of this page:

- **Take the returned refresh token, every time.** Strava, WHOOP, Oura, Garmin and Fitbit all invalidate the old one immediately. Miss the returned value once and every later refresh answers `400 invalid_grant`.
- **Serialize refreshes per user.** Two refreshes racing for the same person rotate the token out from under each other, so a codebase whose persistence is perfectly correct still throws intermittent `invalid_grant` under load.
- **Bound the 401 retry.** A 401 that triggers a refresh that triggers a retry that 401s again is a loop, and it is your own denial-of-service pointed at the provider.
- **Treat `invalid_grant` as terminal.** A dead grant cannot be retried into life. Every attempt after the first is work that can never succeed.

One rule sits alongside those and is not in the code path at all: a `403` is never refreshed. It means the token is authentic and the scope is missing, and a refresh mints the same scopes the user already granted — see [what OAuth scopes are](/learn/what-are-oauth-scopes) for why the granted set is rarely the requested set. The cross-provider split between the two status codes is in [fitness API 401 Unauthorized](/fix/fitness-api-401-unauthorized).

## The implementation

The canonical file is `cookbook/refresh-rotation.mjs` in this site's repository, and `cookbook/refresh-rotation.test.mjs` runs against it on every CI run. The listing below is a verbatim copy of that file. It has no runtime dependencies, needs Node 20 or newer, and takes its store, its clock and its `fetch` as arguments, so it runs in a test without a network.

```js
/**
 * refresh-rotation.mjs — a rotation-safe OAuth token client for fitness APIs.
 *
 * WHAT THIS IMPLEMENTS
 *   Providers that rotate refresh tokens (Strava, WHOOP, Oura, Garmin, Fitbit)
 *   invalidate the old refresh token the moment they hand you a new one. Four
 *   disciplines keep that from breaking every user at once:
 *     1. Refresh is single-flight PER USER — concurrent callers await one
 *        token-endpoint call instead of racing each other into `invalid_grant`.
 *     2. The returned refresh token is persisted ATOMICALLY with the access
 *        token, and the write is awaited before the refresh promise resolves.
 *     3. A 401 on an API call triggers exactly one refresh + retry. A second
 *        401 raises instead of looping.
 *     4. A refresh that fails with `invalid_grant` marks the grant DEAD
 *        (re-authorization required) rather than retrying forever. Every other
 *        refresh failure is transient and leaves the stored grant untouched.
 *   A 403 is never refreshed: it means the token is authentic but the scope is
 *   missing, and a refresh mints the same scopes the user already granted.
 *
 * PATTERN DOCUMENTED AT
 *   https://aifitnessapi.com/fix/refresh-token-not-working
 *   https://aifitnessapi.com/fix/strava-api-401-unauthorized
 *   https://aifitnessapi.com/learn/what-are-oauth-scopes
 *
 * Everything is injectable (store, fetch, clock) so this file is testable
 * without a network or a real provider. No runtime dependencies. Node 20+.
 *
 * MIT — from aifitnessapi.com/cookbook
 */

/** HTTP methods aside, this is the only content type OAuth token endpoints take. */
const FORM_URLENCODED = "application/x-www-form-urlencoded";

/** Default proactive-refresh buffer: refresh when < 5 minutes of life remain. */
const DEFAULT_REFRESH_SKEW_MS = 5 * 60 * 1000;

/**
 * The grant is gone: revoked, expired, or rotated out from under us. Retrying
 * cannot fix this — the user has to authorize again.
 */
export class DeadGrantError extends Error {
  constructor(userId, reason, detail) {
    super(`grant for user ${userId} is dead (${reason}) — re-authorization required`);
    this.name = "DeadGrantError";
    this.userId = userId;
    this.reason = reason;
    this.detail = detail;
    /** Callers branch on this to route the user back through authorize. */
    this.requiresReauth = true;
  }
}

/** The token endpoint failed in a way that may succeed later. Grant untouched. */
export class RefreshFailedError extends Error {
  constructor(userId, status, payload) {
    super(`refresh for user ${userId} failed with status ${status}`);
    this.name = "RefreshFailedError";
    this.userId = userId;
    this.status = status;
    this.payload = payload;
    this.retryable = true;
  }
}

/** Refreshed once, retried once, still 401. Something else is wrong — stop. */
export class UnauthorizedAfterRefreshError extends Error {
  constructor(userId, url) {
    super(`user ${userId} still 401 after one refresh+retry of ${url}`);
    this.name = "UnauthorizedAfterRefreshError";
    this.userId = userId;
    this.url = url;
    this.status = 401;
  }
}

/**
 * Granted scope comes back space-delimited from most providers and
 * comma-delimited from Strava. Accept both rather than shipping two parsers.
 */
export function parseScope(value) {
  if (!value) return [];
  if (Array.isArray(value)) return value.slice();
  return String(value)
    .split(/[\s,]+/)
    .filter(Boolean);
}

function isOk(res) {
  if (typeof res?.ok === "boolean") return res.ok;
  return res?.status >= 200 && res?.status < 300;
}

async function readJson(res) {
  try {
    return await res.json();
  } catch {
    return null;
  }
}

/**
 * Providers disagree on how they express expiry. Strava sends `expires_at` in
 * epoch SECONDS; most others send `expires_in` in seconds. Normalize to epoch ms.
 */
function expiryFromPayload(payload, nowMs, defaultTtlSec) {
  const at = payload?.expires_at;
  if (typeof at === "number" && Number.isFinite(at)) {
    // Below ~1e11 the value cannot be milliseconds this century, so it is seconds.
    return at < 1e11 ? at * 1000 : at;
  }
  const inSec = Number(payload?.expires_in);
  if (Number.isFinite(inSec) && inSec > 0) return nowMs + inSec * 1000;
  return nowMs + defaultTtlSec * 1000;
}

/**
 * @typedef {object} GrantRecord
 * @property {string|null} accessToken
 * @property {string|null} refreshToken
 * @property {number} expiresAt        epoch ms
 * @property {string[]} [scope]        granted scope, as returned by the provider
 * @property {"active"|"dead"} status
 * @property {string|null} [deadReason]
 */

/**
 * @typedef {object} GrantStore
 * @property {(userId: string) => Promise<GrantRecord|null>} load
 * @property {(userId: string, record: GrantRecord) => Promise<void>} save
 *   MUST write the whole record in one atomic operation. A store that writes
 *   the access token and the refresh token in two statements recreates the bug
 *   this file exists to prevent.
 */

/**
 * @param {object} options
 * @param {GrantStore} options.store
 * @param {string} options.tokenEndpoint
 * @param {string} [options.clientId]
 * @param {string} [options.clientSecret]
 * @param {typeof globalThis.fetch} [options.fetch]
 * @param {() => number} [options.now]  epoch ms; injected in tests
 * @param {number} [options.refreshSkewMs]
 * @param {number} [options.defaultTtlSec]
 */
export function createRotatingTokenClient({
  store,
  tokenEndpoint,
  clientId,
  clientSecret,
  fetch: fetchImpl = globalThis.fetch,
  now = () => Date.now(),
  refreshSkewMs = DEFAULT_REFRESH_SKEW_MS,
  defaultTtlSec = 3600,
} = {}) {
  if (!store || typeof store.load !== "function" || typeof store.save !== "function") {
    throw new TypeError("store with load()/save() is required");
  }
  if (!tokenEndpoint) throw new TypeError("tokenEndpoint is required");
  if (typeof fetchImpl !== "function") throw new TypeError("fetch is required");

  /** userId -> in-flight refresh promise. This map IS the per-user lock. */
  const inFlight = new Map();

  async function requireLiveGrant(userId) {
    const record = await store.load(userId);
    if (!record) throw new DeadGrantError(userId, "no_grant");
    if (record.status === "dead") {
      throw new DeadGrantError(userId, record.deadReason || "revoked");
    }
    return record;
  }

  async function performRefresh(userId) {
    const record = await requireLiveGrant(userId);
    if (!record.refreshToken) {
      // No refresh token was ever issued — usually a missing offline-access
      // scope (WHOOP's `offline`). Re-authorize; there is nothing to refresh.
      throw new DeadGrantError(userId, "no_refresh_token");
    }

    const body = new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: record.refreshToken,
    });
    if (clientId) body.set("client_id", clientId);
    if (clientSecret) body.set("client_secret", clientSecret);

    const res = await fetchImpl(tokenEndpoint, {
      method: "POST",
      headers: { "content-type": FORM_URLENCODED, accept: "application/json" },
      body: body.toString(),
    });
    const payload = await readJson(res);

    if (!isOk(res)) {
      if (payload?.error === "invalid_grant") {
        // The grant is dead. Clear the tokens in the SAME atomic write that
        // sets the dead flag, so a crash cannot leave a half-dead record that
        // some other worker will keep retrying.
        await store.save(userId, {
          ...record,
          accessToken: null,
          refreshToken: null,
          expiresAt: 0,
          status: "dead",
          deadReason: "invalid_grant",
          deadAt: now(),
        });
        throw new DeadGrantError(userId, "invalid_grant", payload?.error_description);
      }
      // Anything else (5xx, invalid_client, a proxy hiccup) is transient from
      // the grant's point of view. Do NOT touch the stored refresh token:
      // deleting a good token on a 503 turns an outage into a re-auth campaign.
      throw new RefreshFailedError(userId, res?.status ?? 0, payload);
    }

    if (!payload?.access_token) {
      throw new RefreshFailedError(userId, res?.status ?? 200, payload);
    }

    const next = {
      ...record,
      accessToken: payload.access_token,
      // ALWAYS take the returned refresh token. Strava's docs: "the refresh
      // token may or may not be the same refresh token used to make the
      // request... always use the most recent refresh token."
      refreshToken: payload.refresh_token ?? record.refreshToken,
      expiresAt: expiryFromPayload(payload, now(), defaultTtlSec),
      scope: payload.scope ? parseScope(payload.scope) : record.scope,
      status: "active",
      deadReason: null,
      rotatedAt: now(),
    };

    // One atomic write, awaited. The refresh promise does not resolve until the
    // new refresh token is durable — otherwise a caller could use the new
    // access token, crash, and come back holding a refresh token the provider
    // has already invalidated.
    await store.save(userId, next);
    return next;
  }

  /**
   * Single-flight refresh, keyed by user. Twenty workers that all notice an
   * expired token at the same instant produce exactly one token-endpoint call.
   */
  function refresh(userId) {
    const existing = inFlight.get(userId);
    if (existing) return existing;

    const promise = (async () => {
      try {
        return await performRefresh(userId);
      } finally {
        if (inFlight.get(userId) === promise) inFlight.delete(userId);
      }
    })();
    inFlight.set(userId, promise);
    return promise;
  }

  /**
   * Returns a usable access token, refreshing proactively when less than
   * `minTtlMs` of life remains. Never waits for a 401 to find out.
   */
  async function getAccessToken(userId, { minTtlMs = refreshSkewMs } = {}) {
    const record = await requireLiveGrant(userId);
    const ttl = (record.expiresAt ?? 0) - now();
    if (record.accessToken && ttl > minTtlMs) return record.accessToken;
    const next = await refresh(userId);
    return next.accessToken;
  }

  async function callOnce(userId, url, init) {
    const token = await getAccessToken(userId);
    const res = await fetchImpl(url, {
      ...init,
      headers: { ...(init?.headers ?? {}), authorization: `Bearer ${token}` },
    });
    return { res, token };
  }

  /**
   * If another worker already rotated while our request was in flight, use the
   * token it stored instead of burning a second rotation. This is what keeps a
   * burst of 401s from turning into a burst of refreshes.
   */
  async function refreshAfterUnauthorized(userId, usedToken) {
    const current = await store.load(userId);
    if (current && current.status !== "dead" && current.accessToken && current.accessToken !== usedToken) {
      return current;
    }
    return refresh(userId);
  }

  /**
   * Authenticated fetch with exactly one refresh + retry on 401.
   *
   * 403 is deliberately NOT retried: it means `insufficient_scope`, and a
   * refresh returns the same scopes the user already granted, so retrying is
   * an infinite loop against a server that is answering correctly.
   */
  async function fetchWithAuth(userId, url, init = {}) {
    const first = await callOnce(userId, url, init);
    if (first.res.status !== 401) return first.res;

    await refreshAfterUnauthorized(userId, first.token);

    const second = await callOnce(userId, url, init);
    if (second.res.status === 401) {
      // One refresh, one retry, done. Looping here is how a bad credential
      // becomes a self-inflicted denial of service against the provider.
      throw new UnauthorizedAfterRefreshError(userId, String(url));
    }
    return second.res;
  }

  /**
   * Kill a grant on an out-of-band signal — e.g. a Strava `athlete` webhook
   * with `updates.authorized === "false"`. Cheaper than learning from a 401.
   */
  async function markRevoked(userId, reason = "revoked") {
    const record = (await store.load(userId)) ?? {};
    await store.save(userId, {
      ...record,
      accessToken: null,
      refreshToken: null,
      expiresAt: 0,
      status: "dead",
      deadReason: reason,
      deadAt: now(),
    });
  }

  /** Read the GRANTED scope, not the requested one. Users deselect scopes. */
  async function hasScope(userId, scope) {
    const record = await store.load(userId);
    return parseScope(record?.scope).includes(scope);
  }

  return {
    getAccessToken,
    refresh,
    fetchWithAuth,
    markRevoked,
    hasScope,
    /** Observability: how many users currently have a refresh in flight. */
    pendingRefreshCount: () => inFlight.size,
  };
}

/**
 * Reference in-memory GrantStore. Real deployments swap this for a single
 * UPDATE ... SET access_token = $1, refresh_token = $2, expires_at = $3
 * statement — one row, one write, one transaction.
 */
export function createMemoryGrantStore(initial = {}) {
  const rows = new Map(Object.entries(initial));
  return {
    async load(userId) {
      const row = rows.get(userId);
      return row ? { ...row } : null;
    },
    async save(userId, record) {
      rows.set(userId, { ...record });
    },
    /** Test/debug affordance only. */
    _rows: rows,
  };
}
```

## How to adapt it

**Swap the store first.** `createMemoryGrantStore` exists so the tests can run; delete it in your copy. Implement `load` and `save` against your database, and make `save` one statement — a single `UPDATE` setting the access token, the refresh token and the expiry together. Two statements is the original bug with extra steps.

**Decide how far the lock has to reach.** The in-flight `Map` serializes refreshes inside one process. That is enough for a single worker and not enough for a fleet: if several pods can refresh the same user, keep the shape and back the lock with a Postgres advisory lock or a Redis lock keyed on the user id, taken inside `performRefresh` and released in the same `finally`.

**Point `expiryFromPayload` at your provider's field.** The function already handles `expires_at` in epoch seconds and `expires_in` in seconds, which covers the providers we have looked at, but confirm yours against its current docs rather than against this file.

**Tune the buffer, do not remove it.** `refreshSkewMs` defaults to five minutes of remaining life. Refreshing on a buffer rather than on a 401 shrinks the window in which two workers can race at all. Note that at least one provider only mints a genuinely new access token when the current one is close to expiry, so verify the behaviour before tightening the number.

**Wire `markRevoked` to your deauthorization signal.** Learning that a user disconnected from a failed refresh is the slow way. If your provider emits an event when someone revokes you, call `markRevoked` from that handler and stop the traffic before it starts.

**Log `error_description`, not just the status.** `invalid_grant` is overloaded — the provider puts the real reason in the description field, and that string is the difference between a five-minute diagnosis and an afternoon.

## The contract the tests hold it to

Each bullet is one test in `cookbook/refresh-rotation.test.mjs`. They assert on observable state — call counts, what the store holds, which error class came back — never on the client's internal decisions.

- Twenty-five concurrent callers for one expired user produce **exactly one** token-endpoint call, exactly one store write, and the same new token for everyone.
- Two users refreshing at once produce **two** calls, not one: the lock is per user, not global.
- Rotation persists the new refresh token, the new access token and the normalized expiry in **one** write, and that write is durable before the refresh promise resolves. The store commits on a later tick, so a missing `await` fails the test.
- A token response with no `refresh_token` field leaves the stored one in place.
- A 401 produces one refresh and one retry, in that order, with the retry carrying the new bearer token. A second 401 raises `UnauthorizedAfterRefreshError` instead of looping.
- A 401 that the refresh fixes returns the retried response.
- A 403 is returned to the caller untouched, with zero token-endpoint calls.
- `invalid_grant` marks the grant dead, clears both tokens in the same write, and every later call — token or API — short-circuits with `DeadGrantError` and makes no network request at all.
- A 503 from the token endpoint raises a retryable error, leaves the stored refresh token intact and active, releases the lock, and the very next attempt succeeds.
- A token with plenty of life left is served from the store with no refresh; a token inside the proactive buffer is refreshed before it expires.
- `markRevoked` kills a grant out of band, and `hasScope` reads the granted set — a declined scope reads as absent.
- Scope parsing accepts both the space-delimited and the comma-delimited forms, because reusing one delimiter's parser on the other provider is a real and quiet bug.

## What it deliberately does not do

It does not implement the authorization-code exchange, PKCE, or the initial consent flow — those are one-time paths with different failure modes, and the [integration guides](/integrate) cover them per provider. It does not rate-limit; a token refresh does not count against the API budget on at least one provider, but the calls you make with the token do. And it does not decide what to show a user whose grant just died. That is a product question, and the honest answer is usually a re-connect prompt rather than a silent retry.

## FAQ

### Is this recipe safe to paste into a production backend?

It is written to be, with one caveat about scale. The logic — per-user single flight, atomic persistence, bounded retry, dead-grant terminal state — is what a correct implementation needs, and the test file holds it to that contract on every CI run. The caveat is that the in-flight lock is a Map inside one process, so it serializes refreshes within a single worker only. If several processes or pods can refresh the same user at the same time, keep this shape but back the lock with something shared, such as a Postgres advisory lock or a Redis lock keyed by user id.

[Permalink](https://aifitnessapi.com/cookbook/refresh-rotation#faq-1)

### How would I plug my own database into the grant store?

Implement two methods: load(userId), returning the stored record or null, and save(userId, record), writing the whole record in one statement. The single hard requirement is that save be atomic — one UPDATE setting the access token, the refresh token and the expiry together. A store that writes the access token in one statement and the refresh token in another recreates the exact bug this recipe exists to prevent, because a crash between the two leaves you holding a refresh token the provider has already invalidated.

[Permalink](https://aifitnessapi.com/cookbook/refresh-rotation#faq-2)

### What does the client do when the token endpoint returns 503 rather than invalid_grant?

It raises a retryable error and leaves the stored grant completely untouched. That distinction is deliberate: invalid_grant means the credential is gone and no retry can help, so the grant is marked dead and the tokens are cleared. Anything else — a 5xx, a proxy hiccup, invalid_client — is transient from the grant's point of view. Clearing a good refresh token on an outage converts a bad afternoon into a re-authorization campaign across your whole user base.

[Permalink](https://aifitnessapi.com/cookbook/refresh-rotation#faq-3)

### Does the recipe still work with a provider that does not rotate refresh tokens?

Yes, and that is the point of writing it this way. When the token response omits refresh_token, the client keeps the one it already had, so the same code path serves rotating and non-rotating providers. Treating every refresh token as single-use is the rule that is safe everywhere: it costs nothing against a provider that returns the same value, and it is the only correct behaviour against one that does not.

[Permalink](https://aifitnessapi.com/cookbook/refresh-rotation#faq-4)
