Auto Token Refresh with Axios and MMKV in React Native
Setting up automatic JWT token refresh in React Native using Axios interceptors and MMKV, with a queue to prevent duplicate refresh calls when multiple requests hit 401 at the same time.
- react-native
- axios
- jwt
- refresh-token
- mmkv
- typescript
Setting up automatic JWT token refresh in React Native using Axios interceptors and MMKV for token storage. If you’re looking for a web or Ky-based version, check out this post.
Why MMKV for token storage?
MMKV reads are synchronous. Inside an Axios request interceptor, you can read the token without await — no async chain, no extra complexity. AsyncStorage works too but you’ll need to handle the async differently.
Install dependencies
npm install axios react-native-mmkv zustand
For Expo:
npx expo install react-native-mmkv
MMKV needs a native build — won’t work in Expo Go.
1. Storage
// src/lib/storage.ts
import { MMKV } from 'react-native-mmkv';
export const storage = new MMKV({
id: 'auth',
encryptionKey: 'your-encryption-key', // AES-128, fetch this from Keychain/Keystore in production
});
export const tokenStorage = {
getAccessToken: () => storage.getString('auth.accessToken') ?? null,
setAccessToken: (token: string) => storage.set('auth.accessToken', token),
getRefreshToken: () => storage.getString('auth.refreshToken') ?? null,
setRefreshToken: (token: string) => storage.set('auth.refreshToken', token),
clear: () => {
storage.delete('auth.accessToken');
storage.delete('auth.refreshToken');
},
};
2. Auth store
// src/store/authStore.ts
import { create } from 'zustand';
import { tokenStorage } from '@/lib/storage';
interface AuthState {
accessToken: string | null;
isAuthenticated: boolean;
setTokens: (access: string, refresh: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
accessToken: tokenStorage.getAccessToken(),
isAuthenticated: !!tokenStorage.getAccessToken(),
setTokens: (access, refresh) => {
tokenStorage.setAccessToken(access);
tokenStorage.setRefreshToken(refresh);
set({ accessToken: access, isAuthenticated: true });
},
logout: () => {
tokenStorage.clear();
set({ accessToken: null, isAuthenticated: false });
},
}));
3. Refresh queue
When multiple requests get a 401 at the same time, you only want one refresh call — not one per request. This queue holds the waiting requests and releases them once the refresh is done.
// src/lib/refreshQueue.ts
type Resolver = (token: string | null) => void;
class RefreshQueue {
private isRefreshing = false;
private queue: Resolver[] = [];
waitForRefresh(): Promise<string | null> | null {
if (this.isRefreshing) {
return new Promise<string | null>((resolve) => {
this.queue.push(resolve);
});
}
this.isRefreshing = true;
return null;
}
resolve(token: string) {
this.isRefreshing = false;
this.queue.forEach((r) => r(token));
this.queue = [];
}
reject() {
this.isRefreshing = false;
this.queue.forEach((r) => r(null));
this.queue = [];
}
}
export const refreshQueue = new RefreshQueue();
4. Axios instance
// src/lib/api.ts
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { tokenStorage } from './storage';
import { refreshQueue } from './refreshQueue';
import { useAuthStore } from '@/store/authStore';
const BASE_URL = 'https://api.example.com';
export const api = axios.create({
baseURL: BASE_URL,
timeout: 15_000,
});
// Attach token to every request
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = tokenStorage.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle 401s
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const original = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
if (error.response?.status !== 401 || original._retry) {
return Promise.reject(error);
}
// If a refresh is already running, wait for it
const queued = refreshQueue.waitForRefresh();
if (queued !== null) {
const token = await queued;
if (!token) return Promise.reject(error);
original.headers.Authorization = `Bearer ${token}`;
original._retry = true;
return api(original);
}
// First 401 — do the refresh
original._retry = true;
try {
const refreshToken = tokenStorage.getRefreshToken();
if (!refreshToken) throw new Error('No refresh token');
// Use bare axios here, not the api instance — avoids going through interceptors again
const { data } = await axios.post<{
accessToken: string;
refreshToken: string;
}>(`${BASE_URL}/auth/refresh`, { refreshToken });
useAuthStore.getState().setTokens(data.accessToken, data.refreshToken);
refreshQueue.resolve(data.accessToken);
original.headers.Authorization = `Bearer ${data.accessToken}`;
return api(original);
} catch (err) {
refreshQueue.reject();
useAuthStore.getState().logout();
return Promise.reject(err);
}
}
);
Two things to notice — the refresh call uses bare axios, not api, so it doesn’t trigger the interceptor again. And _retry makes sure a failed refresh doesn’t loop.
5. Usage
// src/services/userService.ts
import { api } from '@/lib/api';
interface UserProfile {
id: string;
name: string;
email: string;
}
export const getProfile = () =>
api.get<UserProfile>('/users/me').then((r) => r.data);
With TanStack Query:
import { useQuery } from '@tanstack/react-query';
import { getProfile } from '@/services/userService';
export const useProfile = () =>
useQuery({ queryKey: ['profile'], queryFn: getProfile });
That’s it. Token attaches on every request, refreshes automatically on 401, and concurrent 401s only trigger one refresh.