Token Refresh — Silent Renewal with the RTK Query Reauth Wrapper

Access tokens expire, and every API call made after expiry returns a 401 Unauthorized response. Without silent refresh, this means the user must log in again every 15 minutes — unacceptable UX. The reauth wrapper wraps RTK Query’s base query function and intercepts 401 responses: it attempts a token refresh, retries the original request with the new token, and only forces a logout if the refresh itself fails. A mutex prevents multiple simultaneous 401s from triggering multiple concurrent refresh attempts.

RTK Query Reauth Wrapper

// src/store/baseQueryWithReauth.js
import { fetchBaseQuery }  from "@reduxjs/toolkit/query";
import { Mutex }           from "async-mutex";   // npm install async-mutex
import { config }          from "@/config";
import { useAuthStore }    from "@/stores/authStore";

const mutex = new Mutex();

const rawBaseQuery = fetchBaseQuery({
    baseUrl: config.apiBaseUrl,
    prepareHeaders: (headers) => {
        // Read directly from Zustand store (outside React components)
        const token = useAuthStore.getState().accessToken;
        if (token) headers.set("Authorization", `Bearer ${token}`);
        return headers;
    },
});

export const baseQueryWithReauth = async (args, api, extraOptions) => {
    // Wait if a refresh is already in progress
    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 refreshToken = localStorage.getItem("refresh_token");
                if (!refreshToken) {
                    // No refresh token — force logout
                    useAuthStore.getState().logout();
                    return result;
                }

                // Attempt token refresh
                const refreshResult = await rawBaseQuery(
                    {
                        url:    "/auth/refresh",
                        method: "POST",
                        body:   { refresh_token: refreshToken },
                    },
                    api,
                    extraOptions
                );

                if (refreshResult.data) {
                    const { access_token, refresh_token } = refreshResult.data;
                    // Store new tokens
                    useAuthStore.getState().setAccessToken(access_token);
                    localStorage.setItem("refresh_token", refresh_token);
                    // Retry the original request
                    result = await rawBaseQuery(args, api, extraOptions);
                } else {
                    // Refresh failed — tokens are invalid, force logout
                    await useAuthStore.getState().logout();
                }
            } finally {
                release();
            }
        } else {
            // Another refresh is in progress — wait and retry
            await mutex.waitForUnlock();
            result = await rawBaseQuery(args, api, extraOptions);
        }
    }

    return result;
};

// Use in createApi:
// baseQuery: baseQueryWithReauth  (instead of fetchBaseQuery)
Note: The Mutex from async-mutex prevents a race condition where multiple requests fail with 401 simultaneously (e.g., three components fetch on mount at the same time). Without the mutex, all three would attempt token refresh concurrently — the first succeeds and stores new tokens, the second and third use the old refresh token (now revoked) and fail. The mutex ensures only the first 401 triggers a refresh; the others wait for the lock to release and then retry with the freshly stored token.
Tip: useAuthStore.getState() accesses the Zustand store outside of React components — useful for the RTK Query wrapper which runs in a non-React context. Zustand’s getState() always returns the current state synchronously, making it safe to call in interceptors, event handlers, and async functions that are not React hooks. This is one of Zustand’s advantages over Context — it is accessible everywhere, not just inside component trees.
Warning: The reauth wrapper calls useAuthStore.getState().logout() when refresh fails, which clears tokens and navigates to /login. But navigation from outside React components requires access to the React Router navigate function, which is only available inside React components. One solution: call window.location.href = "/login" as a fallback (causes a full page reload, clearing all state). A cleaner solution: publish a “force logout” event that a React component listens for and handles with useNavigate.

Wiring the Reauth Wrapper

// src/store/apiSlice.js — use reauth wrapper
import { createApi }              from "@reduxjs/toolkit/query/react";
import { baseQueryWithReauth }    from "./baseQueryWithReauth";

export const blogApi = createApi({
    reducerPath: "blogApi",
    baseQuery:   baseQueryWithReauth,   // ← replaces fetchBaseQuery
    tagTypes:    ["Post", "User", "Comment"],
    endpoints:   (builder) => ({
        // ... endpoints unchanged
    }),
});

Testing the Refresh Flow

Manual testing steps:
1. Log in (tokens stored)
2. Open Redux DevTools — note the access_token value
3. In localStorage, manually set an expired token:
   localStorage.setItem('auth-store', JSON.stringify({
     state: { accessToken: 'expired.jwt.token' },
     version: 0
   }))
4. Trigger any API call (navigate to a page that fetches)
5. Network tab: observe:
   a. The original request → 401
   b. POST /api/auth/refresh → 200 (new tokens)
   c. Original request retried → 200 (success)
6. Verify the new access_token in localStorage/Redux DevTools

Common Mistakes

Mistake 1 — No mutex (race condition on multiple simultaneous 401s)

❌ Wrong — three requests all call refresh concurrently:

if (result.error?.status === 401) {
    await refreshTokens();   // called 3 times simultaneously!
    result = await rawBaseQuery(args, api, extraOptions);
}

✅ Correct — use a mutex to serialise refresh attempts.

Mistake 2 — Infinite refresh loop (refresh endpoint itself returns 401)

❌ Wrong — refresh endpoint failure triggers another refresh attempt:

// rawBaseQuery is also wrapped — so /auth/refresh 401 triggers reauth again!

✅ Correct — use rawBaseQuery (unwrapped) for the refresh call itself.

🧠 Test Yourself

Three components mount simultaneously and all trigger API calls with an expired access token. All three get 401 responses. With the mutex, how many token refresh requests are made to FastAPI?