Quick answer: OAuth looks solved until a token expires mid-session, and the failure is almost always in how refresh tokens are stored and coordinated rather than in the initial login. A client that keeps a token in memory and silently retries on a 401 works for one process, but it breaks the moment multiple requests hit an expired token at once and each triggers its own refresh, or the refresh token is rotated and an older in-flight request invalidates the newer one. The robust pattern is to centralize the token in a single store, serialize refresh behind one lock, and treat the refresh token as a rotating secret that is persisted securely and never duplicated across clients. If refresh logic is left to each request to improvise, the first simultaneous expiry produces either a refresh storm or a logout that the user did nothing to cause.

Most API clients handle the happy path of OAuth well: redirect, exchange the code, attach the bearer token. The design questions concentrate on the less visible part: where the tokens live, what happens when the access token expires, and how a rotating refresh token is reconciled when several requests are in flight. A collection-based client tends to store tokens in a local profile or a per-request config, which is convenient for a single developer but does not model the coordination that a real application needs.

Where token refresh goes wrong

The failure modes cluster around storage and concurrency rather than the protocol's basic redirect flow.

ProblemSymptomCorrect approach
Concurrent refreshMultiple refresh calls, some failSingle in-flight refresh with a lock
Rotating refresh tokenOld token replay invalidates newStore latest, queue waiters
Insecure storageToken readable on diskOS keychain/secure store
Retry loop401 repeated foreverOne retry, then re-auth
Tab/process splitEach holds its own tokenShared store

The rotating refresh token row is the subtle one: providers that issue a new refresh token on every use invalidate the previous one, so two clients racing to refresh leaves one holding a dead secret and forcing a fresh login even though the user's session was valid.

Centralize the token and serialize refresh

Keep one token store and expose a method that returns a valid token, refreshing at most once even when many callers ask at the same time. Callers wait on the same refresh promise rather than each starting their own.

class TokenManager {
  constructor(auth, store) {
    this.auth = auth;
    this.store = store;
    this.refreshing = null;
  }

  async getValidToken() {
    const current = await this.store.read();
    if (current.expiresAt > Date.now() + 5000) return current.accessToken;

    // all concurrent callers share one refresh, none races
    this.refreshing ??= this.#refresh(current.refreshToken);
    try {
      const next = await this.refreshing;
      return next.accessToken;
    } finally {
      this.refreshing = null;
    }
  }
}

Because the refresh promise is cached, a burst of requests that all see an expired token causes exactly one refresh, and every request proceeds with the same new access token.

Handle rotation and secure persistence

When the provider rotates the refresh token, write the new pair to the store before resolving the waiters, and persist it through the secure store rather than a plain config file. A stale read must never overwrite the newer secret.

async #refresh(refreshToken) {
  const next = await this.auth.exchangeRefreshToken(refreshToken);

  // persist the rotated pair first; last write wins only via the lock
  await this.store.write({
    accessToken: next.access_token,
    refreshToken: next.refresh_token,       // new rotating secret
    expiresAt: Date.now() + next.expires_in * 1000,
  });
  return next;
}

On a desktop client the secure store maps to the OS keychain; on a web client the refresh token should stay httpOnly and secure on the server side rather than being exposed to JavaScript. The point is that the token is never duplicated into a per-request profile or a readable file, and a single authoritative copy exists.

Bound the retry and drive it from the contract

On a 401, a request should trigger the central refresh once and retry exactly once; if the refresh itself fails, the client routes to re-authentication rather than looping. A generated client that knows the security scheme and token endpoint from the OpenAPI contract can apply this logic consistently, including the token expiry buffer, so every request uses the same manager instead of embedding bespoke refresh code.

The practical recommendation is to treat token lifecycle as infrastructure: one secure store, one serialized refresh path, explicit handling of rotating refresh tokens, and a single retry before re-auth. If your client works on the first call but logs users out or throws refresh errors after an hour, the problem is not OAuth; it is storage and concurrency that were never centralized.

Frequently asked questions

Why do multiple requests fail when the token expires?

Each request independently sees the 401 and starts its own refresh. With a rotating refresh token, only the first succeeds and the others replay a now-invalid secret. A shared refresh promise fixes this.

Where should refresh tokens be stored?

In a secure store such as the OS keychain for desktop, or an httpOnly secure cookie for web. Never in a plain config file or in JavaScript-readable local storage.

What is a rotating refresh token?

A provider that issues a new refresh token on each use and invalidates the previous one. Clients must persist the latest token immediately; racing refreshes leave one client holding a dead secret.

How many times should a request retry after a 401?

Once, after a central refresh. If the refresh fails or the retried call is still 401, route to re-authentication rather than looping.

How does the API contract help?

It declares the security scheme and token endpoint, so a generated client wires every request to the same token manager with consistent expiry handling instead of per-request refresh logic.

This is part of the Powerduck vs Insomnia comparison — start there for the full picture of reliable auth handling.