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.ts
export interface Tokens {
accessToken: string | null;
refreshToken: string | null;
expiresAt: number | null; // epoch ms
}
export interface TokenEndpointResponse {
access_token: string;
refresh_token?: string;
expires_in: number; // seconds
}
export interface ITokenStore {
getItem(key: string): Promise<string | null>;
setItem(key: string, val: string): Promise<void>;
removeItem(key: string): Promise<void>;
}
export interface IAxiosService {
request<T= any>(config: any): Promise<{ data: T }>;
}
export class TokenService {
constructor(
private readonly store: ITokenStore, // SecureStore/Keychain
private readonly axios: IAxiosService,
private readonly config: { refreshUrl: string; clientId?: string }
) {}
async getTokens(): Promise<Tokens> {
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: TokenEndpointResponse) {
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: number | null, skewMs = 15_000) {
return !expiresAt || Date.now() + skewMs >= expiresAt;
}
// Exchange refresh token for new access token (RFC 6749 §6)
async refresh(): Promise<string | null> {
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 as TokenEndpointResponse;
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.ts
import axios, {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
} from "axios";
import { TokenService } from "../auth/TokenService";
export class AxiosService {
private axios: AxiosInstance;
private isRefreshing = false;
private waiters: Array<(token: string | null) => void> = [];
constructor(private readonly tokens: TokenService, baseURL: string) {
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: AxiosError) => {
const status = error.response?.status ?? 0;
const original = error.config as AxiosRequestConfig & {
_retry?: boolean;
};
// 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);
}
);
}
private async refreshOnce(): Promise<string | null> {
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<T= any>(config: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.axios.request<T>(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.ts
import { QueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
export class ReactQueryService {
readonly queryClient: QueryClient;
constructor(private readonly logger: { debug: Function }) {
this.queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
retry: (failureCount, error) => {
const status = (error as AxiosError)?.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 as AxiosError)?.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.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
export interface AuthState {
isAuthenticated: boolean;
accessToken: string | null;
refreshTokenPresent: boolean; // Boolean flag only; store actual token in secure storage
expiresAt: number | null;
lastRefreshAt: number | null;
}
const initialState: AuthState = {
isAuthenticated: false,
accessToken: null,
refreshTokenPresent: false,
expiresAt: null,
lastRefreshAt: null,
};
export const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
setSession: (
s,
{
payload,
}: PayloadAction<{
accessToken: string;
expiresAt: number;
hasRefresh: boolean;
}>
) => {
s.accessToken = payload.accessToken;
s.expiresAt = payload.expiresAt;
s.lastRefreshAt = Date.now();
s.refreshTokenPresent = payload.hasRefresh;
s.isAuthenticated = true;
},
updateAccessToken: (
s,
{ payload }: PayloadAction<{ accessToken: string; expiresAt: number }>
) => {
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.ts
export class AppLifecycleService {
constructor(
private readonly queryClient: import("@tanstack/react-query").QueryClient,
private readonly tokenService: import("../auth/TokenService").TokenService,
private readonly store: { dispatch: Function },
private readonly disconnectors: Array<() => Promise<void>> = []
) {}
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.ts
export class BiometricsService {
constructor(private readonly secure: ITokenStore) {}
async enable(): Promise<boolean> {
// probe hardware + enrollment; set a preference flag in secure storage
await this.secure.setItem("biometricsEnabled", "true");
return true;
}
async authenticate(): Promise<boolean> {
// 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: any,
emit: (ev: string, data?: any) => void,
log: (msg: string, meta?: any) => void
) {
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)
it("refreshes and stores tokens", async () => {
const store = fakeSecureStore();
const mockAxios = {
request: jest.fn().mockResolvedValue({
data: {
access_token: "A2",
refresh_token: "R2",
expires_in: 3600,
},
}),
} as any;
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 mockAxios = {
request: jest.fn().mockResolvedValue({
data: { access_token: "A2", expires_in: 3600 },
}),
} as any;
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.