Fixing Random Logouts with JWT Refresh Token Rotation
JWT refresh token rotation often causes random user logouts. Learn how to implement server-side synchronization and grace periods to stop race conditions.
Your user opens your dashboard. They see the login screen again. It is the third time this week, and your telemetry logs are screaming about invalid_grant errors. You followed the OAuth2 security documentation to the letter. You used JWTs for stateless sessions and enabled refresh token rotation to shrink the window for token theft.
But your app is effectively denying service to your users.
This happens because you treated authentication as a single-threaded operation. Browsers do not work that way. Modern web apps often fire five or six parallel requests to populate various widgets the moment they load. If your access token expires at that microsecond, every one of those requests sees the expiry, triggers a refresh, and tries to use the same refresh token.
Parallel Requests and Token Rotation
Refresh token rotation works by issuing a new token every time you exchange the old one. The logic is straightforward. If the server sees a refresh token used a second time, it assumes a replay attack is happening. The server kills the entire session family to stop the attacker.
When your client fires concurrent requests, they all attempt to rotate the token. The first request succeeds. The second request arrives milliseconds later. It presents the original, now-invalidated refresh token. The server sees this as a replay attack and returns an invalid_grant error. Your frontend kills the session. Your user gets kicked out.
This is a classic concurrency problem. It is not an attack. It is a mismatch between your security policy and how browsers actually fetch resources.
Using HttpOnly Cookies
Storing tokens in local storage is a bad idea. It exposes your users to XSS-based credential theft. Use HttpOnly, Secure, and SameSite=Strict cookies. These attributes tell the browser that JavaScript cannot touch the token. It stops most session hijacking.
The token family model is the standard for rotation. When a user authenticates, they get a family_id. Every refresh creates a new child token. If a breach happens, you revoke the family_id. This kills the session everywhere.
Moving to cookies adds complexity for CSRF protection. Browsers attach cookies to cross-origin requests automatically. You must enforce strict CSRF token checks on all state-changing endpoints. If you see CSRF errors, make sure your frontend reads a non-HttpOnly cookie or a custom header to verify the request origin. Do not rely on the session cookie alone for authorization.
Server Side Grace Periods
You need a grace period to fix the random logout issue.
Do not invalidate the refresh token the second it is exchanged. Keep the old token in a pending revocation state for 30 to 60 seconds. If another request arrives with that same token during this window, the server accepts it as part of a concurrent burst.
// server/auth/refresh.js
async function handleRefresh(oldToken) {
const tokenRecord = await db.tokens.find(oldToken);
if (tokenRecord.revoked_at) {
// Check if we are inside the 30-second grace window
const isWithinGrace = new Date() - tokenRecord.revoked_at < 30000;
if (isWithinGrace) {
// Re-issue the last successful refresh response without rotating again
return tokenRecord.last_issued_tokens;
} else {
// Genuine replay attack - destroy family
await db.tokens.revokeFamily(tokenRecord.family_id);
throw new Error("invalid_grant");
}
}
// Normal rotation flow
return rotateToken(tokenRecord);
}This lets inflight requests finish. It is a slight relaxation of your security model, but it is the price you pay for a functional user experience in a distributed, asynchronous environment.
The Actual Threat
You might worry that a 30-second window creates a vulnerability. Look at the reality.
An attacker needs to intercept the refresh token, use it, and then the original client must use it within that 30-second window before the rotation finalizes. The risk exists, but it is lower than the alternative: keeping non-rotating tokens for days or weeks.
If you handle high-value financial transactions, try a client-side mutex lock instead. Your frontend uses something like axios-auth-refresh to ensure only one refresh request happens at once. When a request hits a 401, it queues all subsequent requests. It refreshes once, then retries the queue. This is cleaner but forces you to handle the wait state in your UI.
Harden Your Flows
Developers often default to simple expiry. They set an access token to last an hour and forget about it. That is brittle. If an access token is compromised, you cannot kill it until the hour is up.
My rule of thumb is to keep access tokens short, usually 5 to 15 minutes, and keep refresh tokens rotating. Ensure your oauth state mismatch errors are handled by persisting state in a secure cookie during the redirect phase of the OIDC flow. If your state parameter drops, your flow is open to CSRF and your application will fail to verify the callback.
Verification Checklist
Check this before you deploy.
* Concurrency Test: Open two tabs. Clear your network cache. Trigger an action that fires 5+ API requests while the access token is expired. Does your logic handle the burst?
* Invalidation Test: Use a refresh token twice with a 5-minute gap. Does the server respond with invalid_grant? If not, your rotation is broken.
* CSRF Check: Post to an endpoint with the cookie but without the CSRF header. Does the server reject it with a 403?
* Cookie Inspection: Inspect your session cookie in DevTools. Do you see HttpOnly and Secure?
Do not over-engineer a custom OAuth server if you can avoid it. The friction usually stems from the mismatch between strict server security and the messy, concurrent nature of the browser. Implement the grace period and stop fighting the client.