Fixing JWT Refresh Token Rotation and Auth Race Conditions
Master jwt refresh token rotation to stop intermittent logouts. Learn to handle concurrent requests, race conditions, and secure your auth flow for production.
Stateless authentication is a lie most junior developers buy into because they want to skip the "hard" part of managing sessions. They think a JWT is a golden ticket that lets them ignore server state. It isn't. It’s a bearer credential that you can't revoke until it dies. Once you add jwt refresh token rotation to the mix, you’re just building a distributed system that fails every time a user’s browser hits a micro-latency blip.
Stop treating JWTs like a magic wand. If you want a system that doesn't fall over, treat the access token as a short-lived key and the refresh token as a strictly monitored, one-time-use credential.
The Anatomy of a JWT Refresh Token Rotation Race Condition
Most outages here aren't caused by hackers. They’re caused by your frontend firing three parallel requests the second an access token expires.
Your UI hits a dashboard, triggers three widgets, and all three see the 401. If you don't have a queue, all three attempt to refresh.
1. Request A hits the server, gets a new pair, and kills the old refresh token.
2. Request B arrives milliseconds later with that same dead token.
3. Your server detects a replay attack and nukes the entire session.
4. The user is logged out while just trying to view their profile.
It’s elegant in a "everything is on fire" sort of way.
Handling Concurrent Requests with Client-Side Queuing
You don't need a complex backend state machine. You need a simple queue in the frontend. If an access token expires, the interceptor shouldn't spam the server. It should return a Promise that parks the other requests until the first refresh call settles.
// src/services/auth-interceptor.js
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) prom.reject(error);
else prom.resolve(token);
});
failedQueue = [];
};
axiosInstance.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(token => {
originalRequest.headers['Authorization'] = 'Bearer ' + token;
return axiosInstance(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
return new Promise((resolve, reject) => {
refreshAccessToken()
.then(token => {
processQueue(null, token);
resolve(axiosInstance(originalRequest));
})
.catch(err => {
processQueue(err, null);
reject(err);
})
.finally(() => { isRefreshing = false; });
});
}
return Promise.reject(error);
}
);Preventing OAuth State Mismatch
CSRF isn't a problem if you stop using cookies for state where they don't belong. Use an Authorization: Bearer header for access tokens and reserve HttpOnly cookies strictly for the refresh token.
Don't ignore the state parameter during your OAuth dance. OAuth 2.1 demands PKCE, but people often get lazy and skip the state validation. If the state doesn't match what you sent initially, drop the request. It’s an OAuth state mismatch and a blatant sign that someone is trying to hijack the flow.
Secure HttpOnly Cookies
If you're using a cookie for your refresh token, set it to HttpOnly, Secure, and SameSite=Strict.
Is this enough? No. Validate the Origin header on every request. If your backend blindly allows Access-Control-Allow-Origin: *, you’ve defeated the purpose of using cookies for security. Tighten your CORS policy to your exact APP_URL.
Session Management and Token Families
Stop fighting the default behavior of your auth provider. Implement token family revocation.
When a refresh token is burned, update your database:
- token_jti: Unique ID of the current token.
- family_id: A UUID shared by all tokens generated from the original login.
- status: active, used, or revoked.
When a user clicks "Logout," don't just delete one token. Query for the family_id and kill every associated entry. It’s the only way to nuke all sessions across every device simultaneously.
Threat Model: The Replay Attack
| Threat | Mitigating Factor |
| :--- | :--- |
| Token Theft | JWT JTI claim uniqueness + rotation. |
| Race Condition | Client-side request queuing. |
| CSRF | SameSite cookies + strict CORS + custom request headers. |
| OAuth Hijack | PKCE + state parameter verification. |
Practical Verification
1. Test the "Double-Click" Logout: Ensure hitting logout twice doesn't cause a database error that leaves the user in an invalid limbo.
2. Expired Cookie Skew: Configure your refresh cookie to expire 30 seconds *before* the backend invalidates the JWT. Force the refresh early so the user never sees a 401.
3. Audit Token Reuse: Manually inject a used JTI into a refresh request. If your API doesn't return a 403 and revoke the entire family_id immediately, your security isn't working.
4. CORS Validation: Use curl to hit your refresh endpoint from a random domain. If it responds, your CORS policy is garbage.
The Real Silent Killer
Everyone obsesses over the logic, but the real issue is your database index. If you check family_id on every refresh, make sure that column is indexed. I’ve seen teams spike CPU usage to 100% because a simple auth check became a full table scan. You're building an auth service, not a load test. Keep your queries lean. And if the system returns a 403, listen to it. It’s not a bug; it’s the only thing keeping your system from being compromised.
Resources
- OAuth 2.1 Authorization Framework (IETF Draft)
- RFC 7636: Proof Key for Code Exchange by OAuth Public Clients
- OWASP: Cross-Site Request Forgery (CSRF) Prevention Cheat Sheet
- Auth0: Refresh Token Rotation Best Practices
- MDN: Using HTTP Cookies (Secure and HttpOnly flags)
- JWT.io: Introduction to JSON Web Tokens