While I was working with a company on their mobile app, we ran into a bug that sounded impossible:
Users were getting logged out, even though their refresh token was valid for seven hours.
No crash, no obvious error. People just got sent back to the login screen. I spent more than three weeks researching and debugging it. I went through the frontend and backend code over and over, read everything I could find about token refresh flows, and tested theory after theory until I found the actual cause and a fix that worked.
The symptom
- Access token expires, which is expected.
- Refresh token is still valid for hours.
- The user gets kicked to the login screen anyway, and only sometimes. That's the worst kind of bug.
"Sometimes" is the clue. When a bug depends on timing, look for a race condition.
The root cause: everyone refreshes at once
Think about what a typical screen does when it opens. It fires several API calls in parallel: profile, notifications, feed, settings.
If the access token has just expired, all of them fail with 401 at the same time. A naive interceptor handles each failure independently:
GET /profile → 401 → POST /auth/refresh ┐
GET /notifications → 401 → POST /auth/refresh ├─ 4 refreshes racing each other
GET /feed → 401 → POST /auth/refresh │
GET /settings → 401 → POST /auth/refresh ┘
Each request tries to refresh the token on its own. Those refresh calls conflict with each other. With refresh-token rotation, for example, the first refresh invalidates the old refresh token, and every other call is now holding a dead one. One of them fails, the error handler treats it as a failed refresh, and the app logs the user out.
The token was never really invalid. The app just tried to refresh it four times at once.
The fix: one refresh, everyone else waits in a queue
Here's how I solved it:
- Notice that parallel requests are each triggering their own token refresh.
- Build a queue on the frontend to hold incoming requests while a refresh is in progress.
- Let only one request trigger the actual refresh call.
- Make all other requests wait in the queue until that refresh completes.
- Once the new token arrives, update storage and release all queued requests with the new token.
GET /profile → 401 → POST /auth/refresh ──────┐
GET /notifications → 401 → wait in queue ⏳ │
GET /feed → 401 → wait in queue ⏳ │
GET /settings → 401 → wait in queue ⏳ │
▼
new token saved → retry all 4 requests ✅
The code
Here it is as an Axios interceptor. It works the same in React Native and on the web.
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { tokenStore } from './tokenStore'; // wraps SecureStore / AsyncStorage / localStorage
import { logout } from './auth';
const API_URL = 'https://api.example.com';
export const api = axios.create({ baseURL: API_URL });
let isRefreshing = false;
let queue: Array<{
resolve: (token: string) => void;
reject: (error: unknown) => void;
}> = [];
// Release every waiting request, either with the new token or with the error.
function flushQueue(error: unknown, token: string | null) {
queue.forEach(({ resolve, reject }) => (error ? reject(error) : resolve(token!)));
queue = [];
}
// Attach the current access token to every request.
api.interceptors.request.use(async (config) => {
const token = await tokenStore.getAccessToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const original = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Only handle 401s, and only retry a request once.
if (error.response?.status !== 401 || !original || original._retry) {
return Promise.reject(error);
}
original._retry = true;
// This request went out with an old token, and another request has already
// refreshed since. Nothing to refresh: just retry with the token we have now.
const sent = String(original.headers.Authorization ?? '').replace('Bearer ', '');
const current = await tokenStore.getAccessToken();
if (!isRefreshing && current && current !== sent) {
original.headers.Authorization = `Bearer ${current}`;
return api(original);
}
// A refresh is already running: wait in the queue, then retry.
if (isRefreshing) {
const token = await new Promise<string>((resolve, reject) => {
queue.push({ resolve, reject });
});
original.headers.Authorization = `Bearer ${token}`;
return api(original);
}
// This request is the one that refreshes.
isRefreshing = true;
try {
const refreshToken = await tokenStore.getRefreshToken();
// Use bare axios, not `api`, so the refresh call doesn't loop through this interceptor.
const { data } = await axios.post(`${API_URL}/auth/refresh`, { refreshToken });
await tokenStore.save(data.accessToken, data.refreshToken);
flushQueue(null, data.accessToken);
original.headers.Authorization = `Bearer ${data.accessToken}`;
return api(original);
} catch (refreshError) {
// The refresh really failed, so logging out is now the right call.
flushQueue(refreshError, null);
await logout();
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
);
Things worth noticing:
-
isRefreshingis the lock. Only the first401starts a refresh. Every later one gets parked inqueue. - The queue holds promises, not requests. Each waiting request is a pending promise that resolves with the new token, then retries itself.
-
The refresh call uses bare
axios. If it went throughapi, a401from the refresh endpoint would trigger another refresh, and you'd have an infinite loop. -
A late
401doesn't start a second refresh. A slow request sent with the old token can fail after the refresh has already finished. By thenisRefreshingis false again, so without thecurrent !== sentcheck it would refresh a second time, and with rotation that second refresh can fail and log the user out. The bug comes back through the side door. -
_retrystops retry loops. A request that still gets a401after a fresh token is a real auth failure, not a race. - Logout only happens when the refresh itself fails. That's the whole point: a single expired access token should never log anyone out.
How I learned this
I didn't learn this from a course or a tutorial. It came from spending real time on a real product, debugging a painful production issue, and refusing to give up until I understood exactly what was happening under the hood.
Three weeks felt like a long time to spend on one bug. But I came out of it understanding auth flows, interceptors, and race conditions far better than any tutorial could have taught me. Ever since, it's the first thing I check when an app logs users out for no clear reason.
Go deep on the "why" behind your bugs. The fix is useful once. Understanding it keeps paying off.
Have you hit this bug in your own app? Or a different auth race condition? Tell me in the comments.
Top comments (2)
I'd add one more race fixture: start a refresh for account A, log out and sign into account B before it resolves, then let A's response arrive. In the sample, tokenStore.save and flushQueue don't visibly check whether the session changed while the refresh was in flight.
A session-generation check before saving or releasing queued requests could make an old refresh expire instead of overwriting the new login. I'd check the failure path too, so A's late refresh error cannot log B out. I haven't run this code; this is a regression suggestion from the interceptor shown here.
Vụ lỗi refresh token này thực sự là ác mộng vì nó thường không tái hiện được trong môi trường local hay staging nếu dữ liệu test không đủ phức tạp. Mình từng gặp trường hợp tương tự khi race condition xảy ra giữa hai request chạy song song, dẫn đến việc một request dùng token cũ đã bị thu hồi ngay khi request kia vừa mới refresh xong. Kinh nghiệm của mình là luôn phải triển khai cơ chế locking hoặc một hàng đợi request ở phía client để đảm bảo nếu một request đang refresh thì các request khác phải đợi hoặc dùng lại token mới nhất thay vì tự ý gửi request với token cũ PS: the tool I meant is on labagent .tech