@mrzr/api-client

Token Refresh

How @mrzr/api-client refreshes access tokens automatically, sends one refresh for many concurrent 401s, and how to map custom token response shapes.

You don't write refresh logic. The client refreshes in two situations:

  1. Before expiry. If the access token is a JWT that expires within refreshSkewMs (default 30 s), the client refreshes it before sending the next request.
  2. After a 401. If the server answers 401, the client refreshes and retries the request once with the new token.

Either way, requests that arrive during a refresh wait for it. Many 401s at once produce one refresh request, not one per request:

One refresh for many requests

Expire the token, then send 8 requests at the same time. Open DevTools → Network to watch it happen.

  1. 1.The access token is replaced with an expired one.
  2. 2.8 requests go out at once. The server answers each with 401.
  3. 3.The client pauses them and sends a single refresh request.
  4. 4.All 8 requests are retried with the new token and succeed.
GET #1 not sentGET #2 not sentGET #3 not sentGET #4 not sentGET #5 not sentGET #6 not sentGET #7 not sentGET #8 not sent

Refresh requests sent: –

Tabs that share a session take turns refreshing through the Web Locks API, so a rotating refresh token is never spent twice.

When each one runs

The 401 retry happens once per request. A second 401 is returned as-is, so a broken server can't cause a loop. The retry gets a fresh timeout. It's skipped for skipAuth: true and refreshTokenCheck: false.

The early refresh only runs when all of these are true:

  • refreshSkewMs is above 0 (0 turns it off and relies on 401s)
  • the client is in header mode (in cookie mode it can't read the expiry)
  • both an access token and a refresh token are stored
  • the token's expiry is known and falls inside the window

An opaque (non-JWT) token has no readable expiry, so it's never refreshed early; the 401 path still covers it. If you know the expiry, pass expiresAt in the login response or to setTokens.

The refresh request

By default the client sends:

POST /auth/refresh
Content-Type: application/json

{ "refresh": "<refresh token>" }

and reads the new tokens from the response the same way it reads the login response. It carries the CSRF header like any write, and it's bounded by the client timeout.

If the response has a new refresh token, it replaces the old one (refresh-token rotation). If it has only an access token, the old refresh token is kept.

Custom token shapes

If your server uses other field names, describe them:

createClient({
  // response: { result: { jwt: "…", renew: "…", expires_in: 900 } }
  extractTokens: {
    accessKeys: ["jwt"],
    refreshKeys: ["renew"],
    expiresInKeys: ["expires_in"],   // seconds from now
    expiryKeys: ["exp_at"],          // or an absolute time, in seconds or ms
    roots: ["result"],
  },
  // request body: { "refresh_token": "…" }
  buildRefreshBody: { field: "refresh_token" },
});

The same extractTokens is used for login and for refresh. Both options also accept a function, but a function can't be sent into the Web Worker, so passing one turns worker isolation off. Prefer the object forms above.

When refresh fails

Refresh endpoint answersResult
401 or 403The session is cleared and onAuthFailure fires. Tabs sharing the session ("local" or "cookie" storage) are logged out too
5xx, timeout, network errorThe session is kept. The original request fails, and the next one tries again
2xx without tokens, cookie modeSuccess: the server rotated the httpOnly cookies
2xx without tokens, header modeTreated as a rejection: the session is cleared

A server hiccup never logs the user out.

Manual refresh and long uploads

const ok = await api.refresh();   // true when the session is usable again

// A 10-minute upload: refresh first if the token dies within 10 minutes.
await api.post("/upload", file, { uploadSkewMs: 10 * 60_000 });

refresh() returns a boolean, not the token: the token never leaves the worker. Manual calls join any refresh already in flight, so it's safe to call from a heartbeat. In header mode with no refresh token stored it returns false without a request.

uploadSkewMs works even when refreshSkewMs is 0, because it states what this one request needs.

To skip the 401 → refresh → retry flow for one call, pass refreshTokenCheck: false. Do that for auth calls you make yourself (OTP checks, token exchanges), so a failing one can't trigger a refresh.

JWT helpers

The expiry helpers the client uses are exported:

import { getTokenExpiry, isTokenExpired } from "@mrzr/api-client";

getTokenExpiry(token);          // epoch ms, or null if it isn't a JWT with an exp claim
isTokenExpired(token);          // false when the expiry is unknown
isTokenExpired(token, 60_000);  // will it be expired a minute from now?

They only decode. They never verify the signature: that's your server's job.

Debugging

SymptomLikely cause
Refresh runs before every requestThe server returns an already-expired token, expiresAt is in seconds where ms is expected, or client and server clocks differ by more than refreshSkewMs
Refresh never runs earlyOpaque token, refreshSkewMs: 0, no refresh token stored, or cookie mode. All expected; 401s still refresh
Logged out right after logging inextractTokens finds no tokens in your response. Log it with api.post(loginUrl, creds, { skipAuth: true, fullData: true })
401 loopA call you make to an auth endpoint returns 401. Add refreshTokenCheck: false to it

api.onAuthStateChange((s) => console.log(s.expiresAt && new Date(s.expiresAt))) shows each refresh as it happens.

On this page