Overview
Access tokens and refresh tokens are the core credentials used by many modern React applications that call protected APIs. The access token is presented to the API to authorize a request. The refresh token is used to obtain a new access token when the current access token expires or becomes invalid.
This topic matters because frontend auth bugs are easy to create and painful in production. A weak implementation can log users out unnecessarily, send stale tokens, retry requests forever, create multiple simultaneous refresh calls, expose tokens to attackers, or accidentally treat an authorization failure as an expired session.
In React apps, token handling usually appears in API clients, Axios interceptors, RTK Query base queries, route loaders, and auth state providers. The UI needs clear behavior for normal requests, expired access tokens, refresh success, refresh failure, logout, and concurrent requests that fail at the same time.
For interviews, this topic is important because it tests practical security and async control flow. Strong candidates can explain token lifetimes, bearer-token risk, refresh-token rotation, retrying the original request, and queueing failed requests behind one refresh operation.
Core Concepts
Access Tokens
An access token is a credential that allows a client to access protected resources. In browser-based React apps, it is often sent to an API using the Authorization header:
Authorization: Bearer eyJhbGciOi...
Important properties:
- It represents granted authorization.
- It usually has a limited lifetime.
- It may contain scopes or permissions.
- It may be opaque or formatted as a JWT.
- It should be sent only to the intended API.
- It must not be logged.
- Anyone who has a bearer token can use it unless additional sender constraints are used.
The frontend should treat access tokens as sensitive credentials, not as harmless user profile data.
Refresh Tokens
A refresh token is used to request a new access token. Refresh tokens are usually longer-lived and more sensitive than access tokens because they can mint new access tokens.
Common refresh-token behavior:
- The client sends the refresh token to an authorization server or auth endpoint.
- The server validates it.
- The server returns a new access token.
- The server may also return a new refresh token.
- The client replaces old token values.
Example refresh response:
{
"accessToken": "new-access-token",
"refreshToken": "new-refresh-token",
"expiresIn": 900
}
When refresh-token rotation is used, the old refresh token must be discarded after the server issues a new one.
Bearer Token Risk
Most access tokens used by browser apps are bearer tokens. A bearer token grants access to whoever presents it.
Risk implications:
- XSS can steal JavaScript-accessible tokens.
- Logs can leak tokens.
- Browser extensions can sometimes inspect app data.
- Sending tokens to the wrong origin leaks credentials.
- Tokens in URLs can leak through history, referrers, screenshots, and logs.
Practical rules:
- Send tokens in headers, not query strings.
- Use HTTPS.
- Never log tokens.
- Scope tokens narrowly.
- Keep access token lifetime short.
- Prefer secure server-side or cookie-based refresh designs when the architecture supports them.
Expiration Handling
Access token expiration can be handled reactively or proactively.
Reactive handling means:
- Send request.
- API returns
401 Unauthorized. - Client attempts refresh.
- If refresh succeeds, retry the original request.
- If refresh fails, log out or require sign-in.
Proactive handling means:
- Track token expiration time.
- Refresh shortly before expiration.
- Avoid predictable
401failures during active work.
Both approaches are common. Reactive handling is simpler and should still exist because a token can be revoked before its timestamp expires. Proactive handling can improve UX but must handle clock skew, background tab throttling, and refresh failure.
Example expiration helper:
function shouldRefreshSoon(expiresAtMs: number, skewMs = 60_000) {
return Date.now() + skewMs >= expiresAtMs;
}
Do not rely only on decoding a JWT on the client. The server is the authority on whether a token is valid.
Status Codes: 401 vs 403
Token refresh logic should usually respond to 401 Unauthorized, not every auth-related failure.
Typical meaning:
401: the request is unauthenticated or the credentials are invalid or expired.403: the user is authenticated but not allowed to perform the action.
Refreshing a token will not fix missing permissions. A 403 should usually produce an authorization UX, not a refresh attempt.
Also avoid refreshing on:
400validation errors.404missing resources.409conflicts.429rate limiting unless the auth server specifically requires a different flow.5xxserver failures unless the token endpoint itself is being retried carefully.
Retrying the Original Request
The standard user-friendly flow is:
- API request fails with
401. - Client refreshes the access token.
- Client stores the new token.
- Client retries the original request once.
- UI receives the successful response as if the token had been valid.
Axios-style example:
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error);
}
originalRequest._retry = true;
const tokens = await refreshTokens();
tokenStore.set(tokens);
originalRequest.headers.set(
"Authorization",
`Bearer ${tokens.accessToken}`,
);
return api(originalRequest);
},
);
The _retry guard prevents infinite loops if the retried request also returns 401.
Refresh-Token Queue Pattern
When an access token expires, many API calls may fail at nearly the same time. Without coordination, each request may try to refresh.
Problems this causes:
- Too many refresh requests.
- Race conditions when refresh-token rotation is enabled.
- Older refresh responses overwriting newer tokens.
- Some original requests retrying with stale tokens.
- Users being logged out even though one refresh succeeded.
A refresh-token queue pattern ensures one refresh operation runs while other failed requests wait.
Shared promise example:
let refreshPromise: Promise<AuthTokens> | null = null;
async function getFreshTokens() {
if (!refreshPromise) {
refreshPromise = refreshTokens().finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
Axios interceptor using the shared promise:
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error);
}
originalRequest._retry = true;
try {
const tokens = await getFreshTokens();
tokenStore.set(tokens);
originalRequest.headers.set(
"Authorization",
`Bearer ${tokens.accessToken}`,
);
return api(originalRequest);
} catch (refreshError) {
authEvents.emit("sessionExpired");
return Promise.reject(refreshError);
}
},
);
All requests share one refresh result. After it resolves, each original request can retry with the new token.
Queue Pattern with Subscribers
Some apps implement a subscriber queue instead of a shared promise.
Conceptually:
- First failed request starts refresh.
- Later failed requests register callbacks.
- When refresh succeeds, callbacks receive the new token.
- When refresh fails, callbacks reject and the user is logged out.
Simplified example:
let isRefreshing = false;
let waitingRequests: Array<(token: string) => void> = [];
function subscribeToRefresh(callback: (token: string) => void) {
waitingRequests.push(callback);
}
function notifyWaitingRequests(token: string) {
waitingRequests.forEach((callback) => callback(token));
waitingRequests = [];
}
In modern TypeScript, a shared promise is often simpler and easier to test. The key idea is the same: coordinate concurrent failures behind one refresh operation.
RTK Query Mutex Pattern
In RTK Query, token refresh is often implemented by wrapping fetchBaseQuery.
const rawBaseQuery = fetchBaseQuery({
baseUrl: "/api",
prepareHeaders: (headers, { getState }) => {
const token = selectAccessToken(getState() as RootState);
if (token) {
headers.set("authorization", `Bearer ${token}`);
}
return headers;
},
});
Use a mutex so only one refresh runs:
const mutex = new Mutex();
const baseQueryWithReauth: BaseQueryFn<
string | FetchArgs,
unknown,
FetchBaseQueryError
> = async (args, api, extraOptions) => {
await mutex.waitForUnlock();
let result = await rawBaseQuery(args, api, extraOptions);
if (result.error?.status === 401) {
if (!mutex.isLocked()) {
const release = await mutex.acquire();
try {
const refreshResult = await rawBaseQuery(
{ url: "/auth/refresh", method: "POST" },
api,
extraOptions,
);
if (refreshResult.data) {
api.dispatch(tokenReceived(refreshResult.data as AuthTokens));
result = await rawBaseQuery(args, api, extraOptions);
} else {
api.dispatch(loggedOut());
}
} finally {
release();
}
} else {
await mutex.waitForUnlock();
result = await rawBaseQuery(args, api, extraOptions);
}
}
return result;
};
This pattern fits RTK Query because failed queries retry through the same base query after token state changes.
Refresh Token Rotation
Refresh-token rotation means the server issues a new refresh token when a refresh succeeds and invalidates the previous one.
Why it matters:
- A stolen old refresh token becomes less useful.
- Reuse of an invalidated refresh token can indicate token theft.
- The server can revoke the token family after suspected replay.
Frontend implications:
- Replace the stored refresh token atomically.
- Do not allow parallel refresh calls.
- If refresh succeeds but token storage fails, treat the session carefully.
- If the server reports refresh-token reuse, force reauthentication.
Queueing is especially important with rotation. Two parallel refresh calls using the same old refresh token can cause one to succeed and the other to look like token replay.
Storage Trade-Offs
Token storage is a separate deep topic, but it affects refresh behavior.
Common approaches:
- Access token in memory, refresh token in secure
HttpOnlycookie. - Both tokens in secure cookies with backend CSRF protection.
- Access token in memory and refresh handled by a backend-for-frontend.
- Tokens in browser storage, which is simpler but exposed to XSS.
No storage option removes all risk. Cookie-based auth reduces JavaScript token theft but introduces CSRF considerations. JavaScript-readable tokens are easier to attach to headers but increase XSS impact.
The interview answer should connect storage to the threat model, not claim one universal solution.
Logout and Refresh Failure
Refresh can fail for legitimate reasons:
- Refresh token expired.
- Refresh token was revoked.
- User changed password.
- User was disabled.
- Token family was revoked.
- Network request failed repeatedly.
- Auth server is unavailable.
When refresh fails definitively, the app should:
- Clear auth state.
- Stop retrying original requests.
- Clear or reset sensitive cached data.
- Redirect to sign-in or show a session-expired message.
- Avoid showing stale privileged data.
Network failures are more nuanced. Some apps show a temporary offline state instead of immediate logout if the refresh token might still be valid.
Avoiding Infinite Loops
Infinite auth loops happen when refresh logic retries requests that can only fail.
Guardrails:
- Retry the original request only once.
- Mark retried requests with
_retryor similar metadata. - Do not refresh for the refresh endpoint.
- Do not refresh for login or logout endpoints.
- Do not refresh on
403. - Stop when refresh fails.
- Clear auth state after definitive failure.
Example skip:
function isAuthEndpoint(url?: string) {
return url?.includes("/auth/login") || url?.includes("/auth/refresh");
}
Auth retry code should be intentionally boring. The less surprising it is, the easier it is to debug.
Concurrency and Race Conditions
Common race conditions:
- Two refresh calls use the same rotated refresh token.
- One request reads an old access token while another request is refreshing.
- Logout happens while a refresh request is still in flight.
- A background refresh overwrites a newer token.
- A retried request runs after the user switched accounts.
Mitigation strategies:
- Use a single refresh promise or mutex.
- Store token versions or session IDs.
- Clear pending queues on logout.
- Check the active user/session before applying refreshed tokens.
- Avoid keeping auth state in multiple unsynchronized places.
For interviews, naming these race conditions shows practical experience.
Security Boundaries in React
React code runs in the user's browser, so it cannot safely protect secrets from the user or from successful XSS. The frontend can reduce risk, but the server must enforce authorization.
Frontend responsibilities:
- Send tokens only to trusted origins.
- Avoid logging credentials.
- Handle expiration correctly.
- Clear local auth state on logout.
- Avoid exposing privileged UI after auth failure.
- Prevent accidental duplicate refresh flows.
Server responsibilities:
- Validate tokens.
- Enforce scopes and object-level authorization.
- Rotate or constrain refresh tokens.
- Revoke tokens when needed.
- Detect refresh-token reuse.
- Set secure cookie attributes if cookies are used.
The frontend helps with UX and correct request behavior. It is not the security authority.
Common Mistakes
Common mistakes include:
- Treating refresh tokens as harmless because they are not sent to APIs.
- Storing long-lived tokens in places exposed to XSS without understanding the risk.
- Refreshing on every
401without checking the endpoint or retry count. - Refreshing on
403permission failures. - Running many refresh requests in parallel.
- Ignoring refresh-token rotation.
- Retrying original requests with the old token.
- Forgetting to update headers before retry.
- Swallowing refresh failure and leaving the UI in a fake signed-in state.
- Logging tokens or full Axios configs.
- Trusting decoded JWT claims without server validation.
Best Practices
Best practices include:
- Keep access tokens short-lived.
- Treat refresh tokens as high-value credentials.
- Use authorization code with PKCE for browser-based OAuth flows.
- Prefer refresh-token rotation or sender-constrained refresh tokens where available.
- Attach access tokens only to trusted API origins.
- Refresh reactively on
401and optionally proactively near expiration. - Retry the original request once after successful refresh.
- Use a shared promise, mutex, or queue for concurrent refresh failures.
- Clear auth state and sensitive cache on definitive refresh failure.
- Avoid logging token values.
- Test expiration, concurrent
401, refresh success, refresh failure, logout during refresh, and rotated refresh-token behavior.