-
Notifications
You must be signed in to change notification settings - Fork 0
Token Refresh
Two mechanisms, both automatic.
When a request returns 401, the client refreshes and replays it once.
Request ─→ 401 ─→ refresh() ─→ retry with the new token ─→ result
Rules:
- Only one retry. A second 401 is returned as-is, so a broken server can't loop.
- Skipped when
skipAuth: trueorrefreshTokenCheck: false. - The retry gets a fresh timeout budget.
- If refresh fails, the original 401 is returned and
onAuthFailurefires.
Before sending, if the access token expires within refreshSkewMs (default 30 000 ms), the client refreshes first — saving a wasted round trip.
createClient({ refreshSkewMs: 30_000 }); // default
createClient({ refreshSkewMs: 0 }); // disable; rely on 401s only
createClient({ refreshSkewMs: 120_000 }); // for slow or long-running requestsProactive refresh runs only when all of these hold:
- the effective skew is greater than
0 -
authModeis"header"(in cookie mode the client can't read the expiry) - both an access token and a refresh token exist
- the token's known expiry falls inside the window
An opaque token — one with no readable JWT exp — is never considered expired, so it is never proactively refreshed. The server decides via a 401.
Request A ─ 401 ─┐
Request B ─ 401 ─┼─→ ONE refresh call ─→ all three retried with the new token
Request C ─ 401 ─┘
AuthStore.coalesceRefresh keeps the in-flight promise and hands the same promise to every concurrent caller:
coalesceRefresh(task) {
if (this.inFlight) return this.inFlight;
this.inFlight = task().finally(() => { this.inFlight = null; });
return this.inFlight;
}Fifty simultaneous 401s → one network call, no polling, no artificial delay. Across tabs, BroadcastChannel leader election narrows it further — see Multi-Tab Sync.
By default:
POST {baseUrl}{refreshUrl}
Content-Type: application/json
Credentials: per authMode
{ "refresh": "<refreshToken>" }Client-wide headers are merged in. In header mode with no refresh token, the call is skipped entirely — it can't succeed — and auth fails immediately.
createClient({
buildRefreshBody: (refresh) => ({ refresh_token: refresh, grant_type: "refresh_token" }),
});Cookie mode, where the token lives in an httpOnly cookie:
createClient({
authMode: "cookie",
buildRefreshBody: () => ({}), // empty body; the cookie carries everything
});createClient({
refreshUrl: "/api/v1/auth/token/refresh/",
extractTokens: (body) => {
const b = body as { result?: { jwt: string; renew: string; ttl: number } };
if (!b.result) return null;
return {
accessToken: b.result.jwt,
refreshToken: b.result.renew,
expiresAt: Date.now() + b.result.ttl * 1000,
};
},
});Return null when the body contains no tokens; the client treats that as a failed refresh (in header mode) and clears auth.
extractTokens is used by both login() and refresh(), so one function covers both.
⚠️ extractTokensandbuildRefreshBodyare functions and cannot be structured-cloned into a Web Worker. Supplying either automatically disables worker mode. See Web Worker Isolation.
| Situation | Outcome |
|---|---|
| Non-2xx response | Failure → clear auth, broadcast logout, onAuthFailure
|
| Network error / JSON parse failure | Failure → same |
| Tokens extracted | Success → store, broadcast refreshed, return the new access token |
No tokens, authMode: "cookie"
|
Success — the server rotated httpOnly cookies; returns ""
|
No tokens, authMode: "header"
|
Failure → clear auth |
const token = await api.refresh();
// string → new access token ("" in cookie mode)
// null → refresh failed; auth has been clearedRarely needed — the automatic paths cover normal use. It is handy for a "keep me signed in" heartbeat or right before a long operation:
setInterval(() => { void api.refresh(); }, 10 * 60_000);Concurrent manual calls are coalesced with automatic ones, so this is safe.
uploadSkewMs is a per-request override of the proactive window:
await api.post("/upload", form, {
uploadSkewMs: 10 * 60_000, // refresh now if the token dies within 10 minutes
timeout: 0,
});It works even when the client-wide refreshSkewMs is 0, because it expresses a request-specific need. Full details in Uploads and Binary Bodies.
The expiry helpers are exported for your own use:
import { getTokenExpiry, isTokenExpired } from "@mrzr/api-client";
getTokenExpiry(token); // epoch ms, or null if not a readable JWT
isTokenExpired(token); // boolean; false when expiry is unknown
isTokenExpired(token, 60_000); // "will it be expired a minute from now?"They decode base64url in browsers, workers and Node (via atob or Buffer), reconstruct UTF-8 so non-ASCII claims survive, and never verify the signature — verification is the server's job, and doing it client-side would be security theatre.
getTokenExpiry returns null for anything that isn't a three-part JWT with a numeric exp, and isTokenExpired returns false in that case — "unknown" is not "expired".
If your server issues a new refresh token with each refresh, it just works: extractTokens returns both, and AuthStore.apply updates both. Only the keys present in the returned pair are overwritten, so returning access-only leaves the existing refresh token intact.
With rotation plus multi-tab, race conditions become a real risk — two tabs presenting the same refresh token, one of them getting rejected. Leader election plus refresh coalescing prevents this for tabs of the same origin sharing storage. Keep multiTab: true and use a shared storage kind ("local" or "cookie") if you enable rotation.
When refresh fails permanently:
- Tokens are cleared.
-
logoutis broadcast — every other tab clears too. -
onAuthFailure()fires. -
onAuthStateChangeemits{ isAuthenticated: false }. - The original request's result (usually the 401) is returned or thrown.
createClient({
onAuthFailure: () => {
queryClient.clear();
router.push("/login?reason=session-expired");
},
});const api = createClient({
baseUrl,
onAuthStateChange: undefined,
onLog: (e) => console.log(`[api] ${e.method} ${e.url} → ${e.statusCode} (${e.durationMs}ms)`),
});
api.onAuthStateChange((s) =>
console.log("auth:", s.isAuthenticated, s.expiresAt && new Date(s.expiresAt).toISOString()),
);
await api.get("/me", { log: true });Common causes of a refresh loop:
| Symptom | Likely cause |
|---|---|
| Refresh fires on every request | Server returns an already-expired token, or expiresAt is in seconds where ms was expected |
| Refresh never fires | Opaque token (no exp) — expected; the 401 path still works |
| Immediate logout after login |
extractTokens returns null for your response shape |
| 401 loop | Your refresh endpoint itself returns 401 — set refreshTokenCheck: false on any manual auth calls |
Next: Storage Adapters
Getting started
Requests
Authentication
Advanced
Reference
Guides