Robust token management is more than “call refresh on 401.” You need a lifecycle that protects security, avoids race conditions, keeps UI state consistent, and handles edge cases (network loss, device changes, revoked sessions).
This guide provides provider-agnostic patterns for access/refresh tokens, automatic refresh, state sync, and error recovery.
Authentication Lifecycle
Token types
- Access token — short-lived, sent with API calls.
- Refresh token — longer-lived, exchanged for new access tokens.
- ID token (optional) — user identity claims (e.g., JWT).
- Device/linking token (optional) — identify device/session, tie into biometrics.
References: OAuth 2.0, OIDC, JWT
Token Service (Provider-Agnostic)
Encapsulate storage + refresh. Never let UI talk to storage directly.
// services/auth/TokenService.js
export class TokenService {
constructor(
store, // SecureStore/Keychain
axios,
config
) {
this.store = store;
this.axios = axios;
this.config = config;
}
async getTokens() {
const [at, rt, exp] = await Promise.all([
this.store.getItem("accessToken"),
this.store.getItem("refreshToken"),
this.store.getItem("expiresAt"),
]);
return {
accessToken: at,
refreshToken: rt,
expiresAt: exp ? Number(exp) : null,
};
}
async setTokens(r) {
const expiresAt = Date.now() + r.expires_in * 1000;
await this.store.setItem("accessToken", r.access_token);
if (r.refresh_token)
await this.store.setItem("refreshToken", r.refresh_token);
await this.store.setItem("expiresAt", String(expiresAt));
}
async clearTokens() {
await Promise.all([
this.store.removeItem("accessToken"),
this.store.removeItem("refreshToken"),
this.store.removeItem("expiresAt"),
]);
}
isExpired(expiresAt, skewMs = 15_000) {
return !expiresAt || Date.now() + skewMs >= expiresAt;
}
// Exchange refresh token for new access token (RFC 6749 §6)
async refresh() {
const { refreshToken } = await this.getTokens();
if (!refreshToken) return null;
const res = await this.axios.request({
method: "POST",
url: this.config.refreshUrl,
data: {
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: this.config.clientId,
},
});
const payload = res.data;
await this.setTokens(payload);
return payload.access_token;
}
}
Axios Integration (One Refresh At A Time)
Queue requests during refresh; avoid token storms and loops.
// services/http/AxiosService.js
import axios from "axios";
import { TokenService } from "../auth/TokenService";
export class AxiosService {
axios;
isRefreshing = false;
waiters = [];
constructor(tokens, baseURL) {
this.tokens = tokens;
this.axios = axios.create({ baseURL, timeout: 20_000 });
this.axios.interceptors.request.use(async (config) => {
const { accessToken, expiresAt } = await this.tokens.getTokens();
let token = accessToken;
if (this.tokens.isExpired(expiresAt)) {
token = await this.refreshOnce();
}
if (token && config.headers)
config.headers.Authorization = `Bearer ${token}`;
return config;
});
this.axios.interceptors.response.use(
(r) => r,
async (error) => {
const status = error.response?.status ?? 0;
const original = error.config;
// On 401, try a single refresh-then-retry path
if (status === 401 && !original._retry) {
original._retry = true;
const token = await this.refreshOnce();
if (token) {
original.headers = {
...(original.headers || {}),
Authorization: `Bearer ${token}`,
};
return this.axios.request(original);
}
}
return Promise.reject(error);
}
);
}
async refreshOnce() {
if (this.isRefreshing) {
return new Promise((resolve) => this.waiters.push(resolve));
}
this.isRefreshing = true;
try {
const token = await this.tokens.refresh();
this.waiters.forEach((w) => w(token ?? null));
return token ?? null;
} catch (e) {
this.waiters.forEach((w) => w(null));
await this.tokens.clearTokens();
throw e;
} finally {
this.waiters = [];
this.isRefreshing = false;
}
}
request(config) {
return this.axios.request(config);
}
}
React Query Retry Strategy (Auth-Aware)
Retry once on 401 after triggering a refresh; otherwise, use bounded retries for network/server errors.
// services/query/ReactQueryService.js
import { QueryClient } from "@tanstack/react-query";
export class ReactQueryService {
queryClient;
constructor(logger) {
this.logger = logger;
this.queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
retry: (failureCount, error) => {
const status = error?.response?.status;
if (status === 401) return failureCount < 1; // single retry after interceptor refresh
if (!status || status >= 500) return failureCount < 2; // network/5xx
return false;
},
refetchOnWindowFocus: false,
},
mutations: {
retry: (failureCount, error) => {
const status = error?.response?.status;
if (!status || status >= 500) return failureCount < 1;
return false;
},
},
},
});
}
}
Auth State Slice (Consistent App Behavior)
Persist only what you must. Keep sensitive data in secure storage.
// state/authSlice.js
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
isAuthenticated: false,
accessToken: null,
refreshTokenPresent: false,
expiresAt: null,
lastRefreshAt: null,
};
export const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
setSession: (s, { payload }) => {
s.accessToken = payload.accessToken;
s.expiresAt = payload.expiresAt;
s.lastRefreshAt = Date.now();
s.refreshTokenPresent = payload.hasRefresh;
s.isAuthenticated = true;
},
updateAccessToken: (s, { payload }) => {
s.accessToken = payload.accessToken;
s.expiresAt = payload.expiresAt;
s.lastRefreshAt = Date.now();
},
clearSession: (s) => {
s.isAuthenticated = false;
s.accessToken = null;
s.expiresAt = null;
s.lastRefreshAt = null;
s.refreshTokenPresent = false;
},
},
});
Secure storage (Keychain/Keystore/SecureStore) should hold refresh tokens and, ideally, access tokens. Redux/state only needs derived flags and timestamps.
References: Expo SecureStore • AsyncStorage
App Lifecycle: Sync & Reset
When refresh fails, perform a clean reset: clear caches, disconnect SDKs, reset analytics, and navigate to sign-in.
// services/lifecycle/AppLifecycleService.js
export class AppLifecycleService {
constructor(queryClient, tokenService, store, disconnectors = []) {
this.queryClient = queryClient;
this.tokenService = tokenService;
this.store = store;
this.disconnectors = disconnectors;
}
async fullSignOut() {
await this.tokenService.clearTokens();
this.store.dispatch({ type: "auth/clearSession" });
await Promise.allSettled(this.disconnectors.map((fn) => fn()));
await this.queryClient.clear();
// Optionally reload app shell or navigate to Auth stack
// e.g., Updates.reloadAsync() or navigation.reset(...)
}
}
Optional: Biometrics as a Gate (Not a Provider)
Biometrics should unlock locally stored credentials/refresh tokens, not replace your server-side session model.
// services/auth/BiometricsService.js
export class BiometricsService {
constructor(secure) {
this.secure = secure;
}
async enable() {
// probe hardware + enrollment; set a preference flag in secure storage
await this.secure.setItem("biometricsEnabled", "true");
return true;
}
async authenticate() {
// call platform biometrics; if success, allow reading refresh token
return true;
}
}
Error Handling & Recovery
Classify and react:
- 401: try single refresh; if it fails → clear session and route to sign-in.
- 403: show “forbidden” and consider role/permission re-fetch.
- 5xx / network: bounded retries + offline UI cues.
- Unknown: log with minimal PII; degrade gracefully.
// central HTTP error hooks
function onHttpError(e, emit, log) {
const status = e?.response?.status ?? 0;
if (status === 401) emit("auth:unauthorized");
else if (status === 403) emit("auth:forbidden");
else if (!status) emit("network:down");
log("http_error", { status, url: e?.config?.url });
}
Tests
Unit (TokenService)
// Test helper function
const createMockSecureStore = () => ({
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
});
it("refreshes and stores tokens", async () => {
const store = createMockSecureStore();
const mockAxios = {
request: jest.fn().mockResolvedValue({
data: {
access_token: "A2",
refresh_token: "R2",
expires_in: 3600,
},
}),
};
const svc = new TokenService(store, mockAxios, {
refreshUrl: "/auth/refresh",
});
await svc.setTokens({
access_token: "A1",
refresh_token: "R1",
expires_in: 1,
});
await svc.refresh();
expect(mockAxios.request).toHaveBeenCalled();
expect(await store.getItem("accessToken")).toBe("A2");
});
Integration (Axios + Refresh)
it("retries once after 401 using queued refresh", async () => {
const store = createMockSecureStore();
const mockAxios = {
request: jest.fn().mockResolvedValue({
data: { access_token: "A2", expires_in: 3600 },
}),
};
const svc = new TokenService(store, mockAxios, {
refreshUrl: "/auth/refresh",
});
await svc.setTokens({
access_token: "expired",
refresh_token: "R1",
expires_in: 1,
});
const http = new AxiosService(svc, "https://api.example.com");
// First call 401, then refresh, then success
mockAxios
.onGet("/protected")
.replyOnce(401)
.onGet("/protected")
.replyOnce(200, { ok: true });
const res = await http.request({ method: "GET", url: "/protected" });
expect(res.status).toBe(200);
});
Key Principles
- Keep token logic in a single service; UI never touches tokens directly.
- Refresh once and queue follow-up requests; prevent token storms.
- Use secure storage for refresh tokens; keep Redux lean (flags/timestamps).
- On refresh failure, clear session and reset app state deterministically.
- Test unit + integration paths: expired tokens, network loss, revoked refresh.
- Log minimally and avoid sensitive data in telemetry.