loke.dev
Header image for Fixing Random Logouts: JWT Refresh Token Rotation Strategies
authentication security web-development jwt oauth

Fixing Random Logouts: JWT Refresh Token Rotation Strategies

Solve random logouts caused by JWT refresh token rotation race conditions. Learn to implement secure token exchange patterns and robust session management.

Published 4 min read

Check your logs. A user loads a dashboard, your frontend fires four parallel requests, and suddenly three of them return 401 Unauthorized while the fourth succeeds. The router catches the 401 and boots the user to the login screen. Your user just lost their session despite having valid credentials five seconds ago.

This is a classic race condition caused by naive JWT refresh token rotation. You built a security feature to stop session hijacking. Instead, you built a distributed system bug that punishes users for having high-latency networks or greedy frontend code.

Concurrent Requests and Token Rotation

The idea behind refresh token rotation is simple. Every time you swap a refresh token for a new access token, the old refresh token dies. The server issues a new pair. This creates a chain. If an attacker steals a token, they can only use it once. If they try again, the server catches the reuse and nukes the whole chain.

The trouble starts when your app fires five requests the moment a token expires. Request A hits the refresh endpoint and grabs the new pair. The server marks the old token as revoked. Request B, already in flight with the old token, hits the server next.

Because the database says that token is burned, the server rejects Request B. If your logic is aggressive, it thinks this is an attack and kills the user's session. You have effectively launched a denial of service attack on your own users.

The Grace Period Solution

You cannot force the frontend to serialize these requests. Frameworks like React or Vue are built for parallel fetching. Your backend needs a grace period.

When a refresh token arrives that has already been revoked, don't trigger the security alarm immediately. Check if the revocation happened within a tiny window, usually 30 seconds. If the token family matches the current active one, you treat this as a collision rather than a hack.

// Example: Validating a refresh token in Node.js/Express
async function handleTokenRefresh(oldRefreshToken) {
  const tokenRecord = await db.tokens.find({ token: oldRefreshToken });

  if (!tokenRecord) {
    throw new Error("Invalid token");
  }

  if (tokenRecord.revoked) {
    const isWithinGracePeriod = Date.now() - tokenRecord.revokedAt < 30000;
    
    if (isWithinGracePeriod && tokenRecord.isCurrentFamily) {
      // It's a race condition. Return the existing new pair.
      return getLatestActiveTokens(tokenRecord.familyId);
    }
    
    // Genuine reuse attack. Kill the family.
    await revokeFamily(tokenRecord.familyId);
    throw new Error("Security violation: Token reuse detected");
  }

  return rotateToken(tokenRecord);
}

This keeps the rotation logic intact. You aren't creating a vulnerability because the window is too short for an attacker to reliably exploit.

Stop Using LocalStorage

If you store tokens in LocalStorage, stop. It is an open invitation for XSS-based theft. Use HttpOnly, Secure, and SameSite cookies instead.

Your set cookie header should look like this.

Set-Cookie: jrt=TOKEN_VALUE; HttpOnly; Secure; SameSite=Lax; Path=/api/refresh; Max-Age=2592000

The HttpOnly flag keeps the token away from document.cookie. SameSite=Lax defends against CSRF. Just remember to scope your path. Don't put the refresh token on the root path. You only need it for the rotation endpoint.

Cross-Domain Architecture

Developers often trip over CSRF errors when the frontend and backend live on different subdomains. If you feel tempted to set SameSite=None, don't. You are disabling your CSRF protection.

Use a custom header like Authorization or X-Requested-With. Modern browsers treat requests with custom headers as CORS-preflighted requests. This provides implicit CSRF protection because the browser will block the submission if the server doesn't explicitly allow the header.

NextAuth and Edge Collision

If you use NextAuth.js, you might see sessions expiring early during deployments. This happens when the Middleware and your API route handlers fight over the session.

If your Middleware calls an introspection endpoint to check if a session is alive, it might rotate the token before your page component ever gets it.

Keep Middleware validation lightweight. Check the JWT signature locally. Do not touch the database. Let the primary request hit your server to handle the rotation. This stops the double-rotation mess where the Middleware consumes the token before your app can.

Verification Checklist

1. Concurrent Burst Test: Fire 10 simultaneous requests to the protected API using an expired token. All 10 must receive a valid token without a single 401.
2. Replay Protection: Refresh a token. Try to use the old token immediately. It must return a 401.
3. Cross-Origin Isolation: Check your browser network tab. Ensure cookies have the correct SameSite flags and aren't leaking across origins.
4. Database Audit: Ensure your token_families table in Postgres or Redis isn't leaking memory and old families are being purged.

Don't chase a perfectly stateless system. Chase a system that handles state with predictability. Move the complexity from the user's browser into your backend code. That is where you have control.

Resources