</>CodeWithKarani

Users Randomly Logged Out: Fixing next-auth Token Rotation

Karani GeoffreyKarani Geoffrey9 min read

The bug report is always the same sentence: "it keeps logging me out". Not every time, not for everyone, and never when you are watching. Your logs show no errors because the refresh failure was caught and swallowed. Support marks it as "user cleared cookies".

Then someone finds the actual behaviour: the access token was refreshed successfully, the provider returned a brand new refresh token, and your jwt callback threw it away. The next time the access token expires there is nothing valid to refresh with, the provider answers invalid_grant, and the user is bounced to the sign-in page.

The reflex fix is to turn rotation off and keep using one long-lived refresh token forever. Do not do that. A non-rotating refresh token turns any leak into a permanent credential. The whole point of rotation is that a stolen token stops working the moment the legitimate client refreshes. Disabling it does not make your auth simpler, it makes your incident response impossible.

the jwt callback must return a token object that carries the new and the old fields forward, and it must preserve the existing refresh token when the provider does not send a new one.

  • Always return { ...token, ... }. Returning a fresh object with only the new fields silently deletes everything else in the session.
  • Use refresh_token: newTokens.refresh_token ?? token.refresh_token. Several providers only issue a refresh token once.
  • Check your expiry maths. expires_at is in seconds, Date.now() is in milliseconds.
  • Concurrent requests will race to refresh the same token. With a pure JWT session there is nowhere to coordinate, so persist tokens server-side once this matters.

The symptom and the error underneath it

What the user sees is a redirect to sign-in. What your server sees, if you log it, is the provider rejecting the refresh:

{
  "error": "invalid_grant",
  "error_description": "Token has been expired or revoked."
}

And what your session object carries, if you followed the Auth.js guide, is:

RefreshTokenError

The trap is that invalid_grant is the same answer you get when a refresh token genuinely expired, when the user revoked access, and when you presented a token that has already been rotated away. Three very different situations, one error code. That ambiguity is why teams misdiagnose this as "the provider is flaky".

Why the new token gets dropped

There are four distinct ways to lose it, and they look identical from the outside.

1. Returning a new object instead of extending the old one

// Broken: everything not listed here is gone from the session.
return {
  access_token: newTokens.access_token,
  expires_at: Math.floor(Date.now() / 1000 + newTokens.expires_in),
}

Whatever the jwt callback returns is the new token. It is not merged with the previous one. Omit refresh_token and it does not persist, it ceases to exist.

2. Overwriting the refresh token with undefined

// Broken when the provider does not return a new refresh token.
refresh_token: newTokens.refresh_token,

Not every provider rotates on every refresh, and some only ever issue a refresh token on the very first consent. Google, for example, requires access_type: "offline" and typically only returns a refresh token when consent is granted, which is why the Auth.js example passes prompt: "consent" in the authorization params. If the refresh response has no refresh_token field and you assign it anyway, you have replaced a working credential with undefined.

3. Comparing seconds to milliseconds

// Broken: expires_at is in seconds, Date.now() is in milliseconds.
if (Date.now() < token.expires_at) return token

This one is insidious because it fails in the safe-looking direction first. An expires_at of about 1.8 billion compared against a Date.now() of about 1.8 trillion means the condition is always false, so you refresh on every single request. That works, right up until the provider rotates the token and your concurrent requests start stepping on each other, at which point it looks like a random logout problem rather than a comparison bug.

4. The race that no amount of careful callback code fixes

This is the real root cause and it is barely discussed. A JWT session is state stored in a cookie. Two requests that arrive at the same moment both read the same cookie, both see an expired access token, and both POST to the token endpoint with the same refresh token. Under rotation, the provider accepts the first and invalidates that refresh token. The second request now presents a token that was valid when it read it and is not valid any more, and gets invalid_grant.

Two requests, one refresh token t0 Request A and Request B both read the session cookie. Same refresh_token: RT1. Request A POST token endpoint with RT1 200 OK, receives RT2. RT1 is now dead. Request B POST token endpoint with RT1 400 invalid_grant t1 Both responses set the session cookie. Whichever lands last wins. If B lands last, the browser now holds a session with a dead refresh token. A cookie is per-response state. Two responses cannot agree on it, which is why this is not a callback bug. The fix is to move the tokens somewhere both requests can read and write under a lock.
Rotation assumes a single client. Concurrent server-rendered requests are several clients wearing one cookie.

The fix

Step 1: Write the jwt callback so it cannot lose fields

callbacks: {
  async jwt({ token, account }) {
    if (account) {
      // First sign-in: capture the tokens from the provider.
      return {
        ...token,
        access_token: account.access_token,
        expires_at: account.expires_at,
        refresh_token: account.refresh_token,
      }
    }

    // Refresh 60 seconds early so a request never uses a token
    // that expires mid-flight.
    if (Date.now() < (token.expires_at as number) * 1000 - 60_000) {
      return token
    }

    if (!token.refresh_token) {
      return { ...token, error: 'RefreshTokenError' as const }
    }

    try {
      const response = await fetch('https://oauth2.googleapis.com/token', {
        method: 'POST',
        body: new URLSearchParams({
          client_id: process.env.AUTH_GOOGLE_ID!,
          client_secret: process.env.AUTH_GOOGLE_SECRET!,
          grant_type: 'refresh_token',
          refresh_token: token.refresh_token as string,
        }),
      })

      const tokensOrError = await response.json()
      if (!response.ok) throw tokensOrError

      const newTokens = tokensOrError as {
        access_token: string
        expires_in: number
        refresh_token?: string
      }

      return {
        ...token,
        access_token: newTokens.access_token,
        expires_at: Math.floor(Date.now() / 1000 + newTokens.expires_in),
        // Keep the old one if the provider did not issue a new one.
        refresh_token: newTokens.refresh_token ?? token.refresh_token,
        error: undefined,
      }
    } catch (error) {
      console.error('refresh_failed', { error })
      return { ...token, error: 'RefreshTokenError' as const }
    }
  },

  async session({ session, token }) {
    session.error = token.error
    return session
  },
}

Four things in there are deliberate. The spread on every return path. The ?? on the refresh token. The 60 second skew so a token that has 3 seconds left is not handed to a downstream API call. And clearing error on success, because a sticky error field means one transient network failure marks the session broken forever.

The token endpoint URL and parameter names differ per provider. Take them from that provider's OAuth documentation rather than copying Google's into a Microsoft or Okta integration.

Step 2: Type the token so the compiler catches a dropped field

declare module 'next-auth' {
  interface Session {
    error?: 'RefreshTokenError'
  }
}

declare module 'next-auth/jwt' {
  interface JWT {
    access_token: string
    expires_at: number
    refresh_token?: string
    error?: 'RefreshTokenError'
  }
}

With access_token and expires_at declared as required, a return path that forgets them is a compile error rather than a support ticket three weeks later.

Step 3: Surface the failure instead of swallowing it

const session = await auth()
if (session?.error === 'RefreshTokenError') {
  // Send the user through interactive sign-in again.
  redirect('/api/auth/signin?error=RefreshTokenError')
}

A refresh failure and a normal session expiry both end in a sign-in page, but they mean different things and you want to be able to count them separately. Log them with distinct event names so a spike in refresh failures shows up as a spike and not as background noise.

Step 4: Stop the race by making the tokens shared state

If your app has any degree of concurrency (parallel Server Component data fetching counts, so almost every app does) the callback fixes above are necessary but not sufficient. Move the tokens out of the cookie:

  • Use the database session strategy with an adapter, and store the tokens on the account record rather than in the JWT. All concurrent requests then read one row.
  • Wrap the refresh in a short-lived lock keyed by account id, in Redis or with a Postgres advisory lock. The winner refreshes and writes the result; the losers wait briefly and re-read rather than issuing their own refresh.
  • Refresh proactively, well before expiry, so that a burst of traffic is unlikely to coincide with the moment the token needs replacing.

This is the same shape of problem as any duplicate-delivery integration: several processes acting on one piece of state, needing exactly one of them to win. If you have solved it before for M-Pesa callbacks that fire twice, the reasoning transfers directly.

Verification

1. Prove the refresh token survives a cycle. Temporarily log a fingerprint, never the token itself:

console.log('token_cycle', {
  has_refresh: Boolean(token.refresh_token),
  refresh_fp: token.refresh_token
    ? createHash('sha256').update(token.refresh_token).digest('hex').slice(0, 8)
    : null,
  expires_at: token.expires_at,
})

Across a refresh you should see has_refresh stay true, expires_at move forward, and refresh_fp either change (the provider rotated) or stay the same (it did not). What you must never see is has_refresh flipping to false.

2. Force an expiry rather than waiting an hour. Set expires_at to a past timestamp when the session is created in a development build, so every request exercises the refresh path. Then click around the app for a few minutes. If it survives, the callback is correct.

3. Reproduce the race deliberately. With an expired access token, fire several requests at once:

for i in $(seq 1 8); do
  curl -s -o /dev/null -b cookies.txt -c cookies.txt \
    https://localhost:3000/dashboard &
done; wait

Count the number of POSTs your app made to the token endpoint. One is correct. Eight means every concurrent request refreshed independently and seven of them raced.

What people get wrong

The workaroundWhat it actually costs
Disable rotation, reuse one refresh token foreverA leaked refresh token becomes a permanent credential with no natural expiry and no detection signal. This is the single worst option and it is the most commonly suggested one.
Set an enormous session maxAge so nobody noticesExtends the window in which a stolen session cookie is useful, and does nothing about the refresh token being dropped.
Catch the refresh error and return the old token unchangedEvery subsequent request retries with a token you already know is dead, hammering the provider's token endpoint and risking rate limits.
Sign the user out on any refresh failureOne transient network blip logs everybody out. Distinguish a network failure, which is retryable, from invalid_grant, which is not.
Store the access token in localStorage "so the client can refresh"Moves a bearer credential into a place any injected script can read. If you are hardening the front end, see why 'unsafe-inline' in your CSP is not a fix, because these two decisions compound.

When it is still broken

  1. The provider never sent a refresh token at all. Check the first sign-in, not the refresh. Many providers require explicit opt-in (access_type=offline plus a consent prompt for Google, the offline_access scope for several others). If account.refresh_token was undefined on day one, nothing downstream can help.
  2. The session cookie is being chunked. Encrypted JWTs holding several tokens get large, and browsers cap cookies at roughly 4KB, so Auth.js splits them across numbered chunks. A proxy or CDN that drops or reorders cookies can corrupt the session. Keep only what you need in the token.
  3. Multiple instances with different secrets. If AUTH_SECRET differs between replicas, a token encrypted by one instance cannot be decrypted by another, and the user appears logged out at random. Check it is identical everywhere, including preview environments.
  4. Clock skew on the server. Expiry maths depends on the server's clock. A machine several minutes fast will consider every token expired and refresh constantly; several minutes slow and it will hand out tokens that the API rejects. Run NTP.

The thing worth holding onto: rotation is not the source of the bug. The bug is treating a rotating credential as if it were static state you can keep in a cookie and read from several places at once. Fix the callback so nothing is dropped, then fix the storage so nothing races.

Frequently asked questions

Why does next-auth lose the new refresh token after rotating it?
Because whatever the jwt callback returns replaces the entire token, it is not merged with the previous one. If you return a new object containing only the new access token and expiry, the refresh token is deleted rather than preserved. Always return { ...token, ... } and use refresh_token: newTokens.refresh_token ?? token.refresh_token, since several providers only issue a refresh token on first consent.
Should I disable refresh token rotation to stop users being logged out?
No. Rotation is what limits the damage of a leaked refresh token: once the legitimate client refreshes, the stolen token stops working. Reusing one refresh token indefinitely turns any leak into a permanent credential with no expiry and no detection signal. Fix the callback so the rotated token is persisted, and coordinate concurrent refreshes, rather than removing the security property.
What causes invalid_grant when refreshing an OAuth token in Next.js?
Most often you presented a refresh token that has already been rotated away by a concurrent request. Two requests read the same session cookie, both refresh with the same token, the provider accepts the first and invalidates it, and the second gets invalid_grant. The same error is also returned when a token genuinely expired or the user revoked access, which is why it is so easy to misdiagnose.
How do I stop concurrent requests from racing to refresh the same token?
Move the tokens out of the JWT cookie and into shared server-side state, such as the account record when using the database session strategy, then wrap the refresh in a short-lived lock keyed by account id using Redis or a Postgres advisory lock. The winner refreshes and writes the result while the others wait and re-read. Refreshing proactively before expiry also makes collisions much less likely.
#Auth.js#next-auth#OAuth#Next.js#Sessions
Keep reading

Related articles