Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Cannot modify JWT to refresh access_token #12454

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
rhufsky opened this issue Jan 1, 2025 · 30 comments
Open

Cannot modify JWT to refresh access_token #12454

rhufsky opened this issue Jan 1, 2025 · 30 comments
Labels
bug Something isn't working triage Unseen or unconfirmed by a maintainer yet. Provide extra information in the meantime.

Comments

@rhufsky
Copy link
Contributor

rhufsky commented Jan 1, 2025

x^### Environment

  System:
    OS: macOS 15.2
    CPU: (8) arm64 Apple M1 Pro
    Memory: 93.78 MB / 16.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 22.14.0 - /opt/homebrew/opt/node@22/bin/node
    Yarn: 1.22.22 - /opt/homebrew/bin/yarn
    npm: 10.9.2 - /opt/homebrew/opt/node@22/bin/npm
    bun: 1.2.10 - /opt/homebrew/bin/bun
  Browsers:
    Chrome: 135.0.7049.114
    Safari: 18.2
  npmPackages:
    @auth/prisma-adapter: ^2.9.0 => 2.9.0 
    next: 15.3.1 => 15.3.1 
    next-auth: ^5.0.0-beta.26 => 5.0.0-beta.26 
    react: ^19.1.0 => 19.1.0 

Reproduction URL

https://github.com/rhufsky/authdemo

Describe the issue

Cannot update JWT after initial creation at login time, shown by a simplified example. In the current state, it seems that I am unable to implement token refresh as described in https://authjs.dev/guides/refresh-token-rotation.

How to reproduce

Login and watch the jwt() callback. As a sample I create an arbitrary property status and set it to "INITIAL" at the first invocation of jwt().

When jwt() is invoked for a second time, it returns a token with status: "REFRESH".

The new value is never persisted, at the third invocation of jwt(), status is still "INITIAL".

    async jwt({ token, user, account, profile }) {
      console.log(token);
      if (account && profile && user) {
        console.log("INITIAL JWT");
        return {
          ...token,
          status: "INITIAL",
        };
      } else {
        console.log("SUBSQUENT JWT");

        return { ...token, status: "REFRESH" };
      }
    },

Expected behavior

After every invocation of jwt() the returned token should be persisted.

@rhufsky rhufsky added bug Something isn't working triage Unseen or unconfirmed by a maintainer yet. Provide extra information in the meantime. labels Jan 1, 2025
@slickroot
Copy link

I spent 2 days thinking I was doing something wrong, until I made a small example also, the token never updates.

    async jwt({ token, account, user }) {
      console.log('JWT', token)
      if (account && user) {
        console.log('First time')
        token.iteration = 0
        token.expires_at = Math.floor(Date.now() / 1000 + 60)
      }

      if (Date.now() < token.expires_at * 1000) {
        console.log('Still good')
        return token
      }

      console.log('expired')
      return {
        ...token,
        iteration: token.iteration + 1,
        expires_at: Math.floor(Date.now() / 1000 + 60)
      }
    },

After one minute, I just keep seeing expired, and token.iteration never increments, is there a work around to solve this?

next-auth version is: 4.24.11

@rhufsky
Copy link
Contributor Author

rhufsky commented Feb 8, 2025

As far as i understand, the docs say that when you update the JWT in jwt() and return the updated JWT from jwt() the new JWT is "persisted" in that the browser sends the updated JWT with the next request.

This works when jwt() is called for the first time after login.

In any further invocations it seems to that even if you update the JWT and return the updated JWT from jwt(), the JWT is not persisted and the browser keeps sending the original JWT. This makes things like refreshing an access token impossible.

The only way i can think of is keeping access token and refresh token in a database table and manage it from there but this defeats the purpose of having JWTs.

@TwoWheelDev
Copy link

This is also present in the latest beta version: [email protected]

Spent the last few hours trying to work out why my token refresh was always triggering!

@8lane
Copy link

8lane commented Feb 11, 2025

Seeing this too in [email protected]. Cannot refresh access token using JWT strategy. Code is identical to the guide https://authjs.dev/guides/refresh-token-rotation?framework=next-js

@TwoWheelDev
Copy link

This is also present in the latest beta version: [email protected]

Spent the last few hours trying to work out why my token refresh was always triggering!

So fast forward a few hours... and it's working for me now 😕

@8lane
Copy link

8lane commented Feb 11, 2025

This is also present in the latest beta version: [email protected]
Spent the last few hours trying to work out why my token refresh was always triggering!

So fast forward a few hours... and it's working for me now 😕

Mind showing your auth.ts? I've given up after reading various other threads saying token refresh is not possible with app router + next-auth. I have managed to get it working with middleware though.

@philyboysmith
Copy link

I spent hours trying to investigate this. In the end I've given up and rolled my own auth using iron session

@TwoWheelDev
Copy link

Mind showing your auth.ts? I've given up after reading various other threads saying token refresh is not possible with app router + next-auth. I have managed to get it working with middleware though.

@8lane Here's a gist of my auth.ts (I make no claims about the cleanliness of it, but it seems to work) https://gist.github.com/TwoWheelDev/83d263b59f4587ae01f134ef0a7795cc

@8lane
Copy link

8lane commented Feb 12, 2025

Mind showing your auth.ts? I've given up after reading various other threads saying token refresh is not possible with app router + next-auth. I have managed to get it working with middleware though.

@8lane Here's a gist of my auth.ts (I make no claims about the cleanliness of it, but it seems to work) https://gist.github.com/TwoWheelDev/83d263b59f4587ae01f134ef0a7795cc

Thank you 🙏

Aside from your authorize function (which I don't have) mine is pretty much identical to yours. The token refresh works, and I return the updated token in the jwt callback exactly like you are. I then see it in the session callback and update the session like you are. However, in subsequent refreshes, the jwt callback will always give the original jwt and not the refreshed one.

next-auth: 5.0.0-beta.25
next: 14.2.23

Using App Router

@TwoWheelDev
Copy link

Thank you 🙏

Aside from your authorize function (which I don't have) mine is pretty much identical to yours. The token refresh works, and I return the updated token in the jwt callback exactly like you are. I then see it in the session callback and update the session like you are. However, in subsequent refreshes, the jwt callback will always give the original jwt and not the refreshed one.

next-auth: 5.0.0-beta.25 next: 14.2.23

Using App Router

I'm currently using next: 15.1.6 but I wouldn't expect that making a huge difference. Thinking it through, it possibly started working after using the default Next-Auth middleware, instead of cobbling together my own based on the NextJS documentation

export { auth as middleware } from "@/auth"
 
export const config = {
  matcher: [
    '/admin/:path*',
    '/((?!api|_next/static|_next/image|.*\\.png$).*)'
  ],
}

@rhufsky
Copy link
Contributor Author

rhufsky commented Feb 12, 2025

That's basically what I experience. Yes, it is possible to update the JWT, Yes, the updated JWT it gets passed to the session but at the next cycle, the JWT itself stands as it was post - login. For now.

In https://github.com/rhufsky/authdemo I simulate that in the JWT callback by

  • add status="INTIAL" post login
  • add status ="REFRESH" and add refreshCount in each subsequent call to jwt()

Turns out that status stays "INITIAL" for each call and refreshCount stays zero, meaning that the JWT can not be updated.

SOMETIMES however, the JWT can be updated, meaning the status goes to "REFRESH" and refreshCount is non - zero. It looks like there must be a minimum interval between two JWT updates.

@brechtmann
Copy link

brechtmann commented Feb 14, 2025

I had a similar Issue. check if this comment fixes it: #8138 (comment)

It didn't work in the end for me

@slickroot
Copy link

Since OP doesn't use any middleware in the demo that was shared, all solutions talking about middleware.ts aren't solutions to the original problem.

Having said that I use a middleware.ts and replaced it with what @TwoWheelDev was suggesting, but still didn't get anywhere.

@rhufsky
Copy link
Contributor Author

rhufsky commented Feb 17, 2025

Tried a bit what @brechtmann suggested in #8138 , to no avail. While the proposed solution seems to be valid, for me it does not change anything.

On the other hand I really think that there is some race condition, probably inside NextAuth. Most of the time an update of the the JWT does not work, however SOMETIMES it works out of the blue.

@TimoP
Copy link

TimoP commented Feb 18, 2025

Same Problem, NextJS 14.2.8, App Router
Token Update is available in session callback but the next time jwt callback is called, its the initial one from after sign in.

@peterstorm-oi-flex
Copy link

I have a, maybe, related issue, not sure? So feel free to delete my comment if needed.
My initial access token has correct expiring_at but then when I try to refresh it via the refresh_token an update the expiring_at it doesn't persist, and the token is refreshed everytime you reload the page, even though the token is actually still valid and not expired, because my backend is using ABAC to verify the users token, and permissions.

I feel like it's related?

@rhufsky
Copy link
Contributor Author

rhufsky commented Feb 19, 2025

I stumbled across the exact same problem. My sample just tries to narrow it down to prove that the JWT cannot be updated.

@rhufsky
Copy link
Contributor Author

rhufsky commented Apr 23, 2025

Updated to "next-auth": "^5.0.0-beta.26", same problem. JWT cannot be updated.

@rhufsky
Copy link
Contributor Author

rhufsky commented Apr 23, 2025

Just another try to find a solution. My jwt() and session() callbacks look like so:

  callbacks: {
    async jwt({ token, account }) {
      console.log("############# JWT() #############");
      console.log("current JWT");
      console.log(token);
      console.log("#############");

      if (account) {
        console.log("adding INITIAL status to JWT");
        const initialToken = { ...token, status: "INITIAL", refreshCount: 0 };
        return initialToken;
      } else {
        console.log(
          `current JWT status: ${token.status} / ${token.refreshCount}`
        );

        console.log("adding REFRESH status / count to JWT");

        const refreshCount = (token.refreshCount as number) || 0;

        const refreshedToken = {
          ...token,
          status: "REFRESH",
          refreshCount: refreshCount + 1,
          lastRefresh: new Date().toISOString().slice(0, 19).replace("T", " "),
        };
        return refreshedToken;
      }
    },

    async session({ session, token }) {
      console.log("############# Session() #############");
      session.status = token?.status as "INITIAL" | "REFRESH";
      session.refreshCount = token?.refreshCount;
      return session;
    },
  },

When jwt() is invoked at login time, statusis set to "INITIAL". This is also propagated to the session() callback.

Subsequent invocations of jwt() update the token status to "REFRESH". This value gets propagated to the session() callback.

Alas, when jwt()is invoked again, it receives a token with status "INITIAL".

When debugging session.js it turns out that:

session.js, line 43+

const newSession = await callbacks.session({ session, token });
// Return session payload as response
response.body = newSession;
// Refresh JWT expiry by re-signing it, with an updated expiry date
const newToken = await jwt.encode({ ...jwt, token, salt });
// Set cookie, to also update expiry date on cookie
const sessionCookies = sessionStore.chunk(newToken, {
    expires: newExpires,
});
response.cookies?.push(...sessionCookies);
await events.session?.({ session: newSession, token });

newSession contains an updated token. It seems that sessionCookies should contain the new token as well, so the browser should have the correct updated token.

session.js line 24+

const payload = await jwt.decode({ ...jwt, token: sessionToken, salt });
if (!payload)
    throw new Error("Invalid JWT");
// @ts-expect-error
const token = await callbacks.jwt({
    token: payload,
    ...(isUpdate && { trigger: "update" }),
    session: newSession,
});

payload contains the initial token, so for some reason the updated token is lost. There ARE random cases, when payload contains the updated token. Maybe there is subtle race condition that I am unable to pinpoint,

I might be doing something terribly wrong, but to me it looks that there is a problem in auth.js that prevents a JWT from being updated. From searching around I sense that more folks are running into the same problem,

As it is, I cannot reliably update an access token, which somehow defeats the purpose of an Oauth library. @balazsorban44 is there a chance to look into that case? Sorry for the inconvinience.

@LaurenceGA
Copy link

Also running into this issue. Not of the workarounds seem to quite work.

Note there's also an ongoing discussion here: #7558

@rhufsky
Copy link
Contributor Author

rhufsky commented May 4, 2025

After hours and hours of research and reading a number of threads here I came to the conclusion below, if this is wrong, I happily appreciate any suggestions:

  • Token refreshing while using the JWT session strategy as described in https://authjs.dev/guides/refresh-token-rotation simply DOES NOT WORK because AuthJS / NextJS does not seem to want to update the JWT cookie in most cases.
  • There are numerous proposed workarounds, ranging from triggering silent signIn() calls to refreshing the JWT in a middleware to creating token refresh APIs that are called by the client, which in my opinion are cool attempts by really clever people, but are still workarounds.
  • I will try the database strategy to see if that works better for me, otherwise I will explore alternatives such as BetterAuth.

My biggest request would be that the documentation https://authjs.dev/guides/refresh-token-rotation is updated to the reality.

@oleg-butko
Copy link

oleg-butko commented May 5, 2025

From my experience trying to implement nextauthv5 + keycloak the refetchInterval param in SessionProvider must not be 0.

      <SessionProvider
        basePath={'/auth'}
        // refetchInterval={0} // `0` (default), the session is not polled
        refetchInterval={20}
        refetchOnWindowFocus={false}
      >
        <UserButton />
        <ClientExample />
      </SessionProvider>

@LaurenceGA
Copy link

From my experience trying to implement nextauthv5 + keycloak the refetchInterval param in SessionProvider must not be 0.

      <SessionProvider
        basePath={'/auth'}
        // refetchInterval={0} // `0` (default), the session is not polled
        refetchInterval={20}
        refetchOnWindowFocus={false}
      >
        <UserButton />
        <ClientExample />
      </SessionProvider>

Oh wow, yeah, that actually did the trick!

It now has to poll the server from the client side before it will update, and if I load the page with an already expired token there's some weird race condition stuff where it will simultaneously fetch a couple of refresh tokens, but then it seems to come right... But anyway, it's a workaround at least until this is fixed properly

@rhufsky
Copy link
Contributor Author

rhufsky commented May 5, 2025

That looks interesting. I believe that also some race condition makes the oroiginal code work in some rare cases.

Anyway, I went for database strategy, which seems to work quite fine.

@peterstorm-oi-flex
Copy link

I don't even use SessionProvider, what is the benefit/need of doing that?

@LaurenceGA
Copy link

I don't even use SessionProvider, what is the benefit/need of doing that?

I don't use it much, but it's useful if you need direct access to your session data from a client side component.
E.g. I have a websocket connection that requires authentication that pulls the access token out of the session, and that all happens entirely in client components. useSession hook requires a SessionProvider to work.
(I suppose another pattern would be getting the session server side, pull out the access token and passing it through props to the client component that needs it?)

But now, apparently, I use it to update my store session token too

@oleg-butko
Copy link

I don't even use SessionProvider, what is the benefit/need of doing that?

AFAIK it's the only (or maybe just the most straightforward way) to auto update client side UI about being not authenticated.

@oleg-butko
Copy link

some weird race condition stuff where it will simultaneously fetch a couple of refresh tokens

Yes, I had something like that. refreshAccessToken is called twice.
My quick workaround is to use tokensMemStorage. But I'm not sure it it's good. Still looking for better workaround.

import { createStorage } from 'unstorage'
import memoryDriver from 'unstorage/drivers/memory'
export const tokensMemStorage = createStorage({ driver: memoryDriver() })
import { tokensMemStorage } from '@/lib/tokensMemStorage'
const WAITING_REFRESH = 'waiting refresh token status'
const EXPIRED_REFRESH = 'expired refresh token status'
          const TOK_KEY = `accessToken:${extractJti(token.accessToken)}`
          if (await tokensMemStorage.has(TOK_KEY)) {
            const storedVal = await tokensMemStorage.get(TOK_KEY)
            if (storedVal == EXPIRED_REFRESH) {
              msg += `, ${magenta('EXPIRED_REFRESH')} `
              result = null
            } else if (storedVal == WAITING_REFRESH) {
              msg += `, ${magenta('WAITING')} `
              const delay = new Promise(resolve => setTimeout(resolve, 200))
              let timeoutCounter = 50
              while (true) {
                if (timeoutCounter % 10 === 0) {
                  msg += green(' waiting->')
                }
                await delay
                const fromStorage = await tokensMemStorage.get(TOK_KEY)
                if (typeof fromStorage === 'object') {
                  msg += ` found object: ${JSON.stringify({
                    ...fromStorage,
                    accessToken: '...',
                    refreshToken: '...'
                  })}`
                } else {
                  if (fromStorage == EXPIRED_REFRESH) {
                    result = null
                    msg += red('\nfromStorage -> EXPIRED_REFRESH')
                    break
                  } else if (timeoutCounter % 10 === 0) {
                    msg += ` fromStorage: ${fromStorage} ${timeoutCounter}`
                  }
                }
                if (fromStorage != WAITING_REFRESH) {
                  result = { storedToken: fromStorage as JWT }
                  break
                } else if (--timeoutCounter < 0) {
                  if (g_isDebug) debugger
                  result = null
                  msg += red('\nwaiting fromStorage TIMEOUT')
                  break
                }
              } // while (true)
            } else {
              if (typeof storedVal === 'object') {
                msg += `, ${magenta('found')} stored object: `
                msg += JSON.stringify(
                  extractFromToken((storedVal as JWT).accessToken, { calledFrom: 'jwt1' })
                )
              } else {
                msg += `, ${magenta('found <unexpected>:')} `
                msg += JSON.stringify(storedVal)
              }
              result = { storedToken: storedVal as JWT }
            }
          } else {
            msg += `, ${magenta('NOT found')}`
            let nRefreshCounter: number | null = (await tokensMemStorage.get(
              'info:REFRESH_COUNTER'
            )) as number | null
            if (nRefreshCounter === null) {
              nRefreshCounter = 1
              msg += ` ${green('nRefreshCounter = 1')} `
            } else {
              nRefreshCounter += 1
              msg += ` ${cyan('nRefreshCounter += 1')} `
            }
            await tokensMemStorage.set('info:REFRESH_COUNTER', nRefreshCounter)
            await tokensMemStorage.set(TOK_KEY, WAITING_REFRESH)
            const refrRes = await refreshAccessToken(token)
            if (refrRes.newToken) {
              result = { newToken: refrRes.newToken as JWT }
              await tokensMemStorage.set(TOK_KEY, refrRes.newToken)
              msg += blueL(
                `updated accessTokenExpires: ${toLocTime(
                  refrRes.newToken.accessTokenExpires as number
                )}, `
              )
              const refreshTokenExp = (refrRes.newToken.accessTokenExpires - Date.now()) / 1000
              const refreshExpiredWithTimeBuffer = refreshTokenExp - TIME_BUFFER_SEC
              if (refreshExpiredWithTimeBuffer < 0) {
                msg += red(` refresh expired (${refreshExpiredWithTimeBuffer.toFixed(0)})`)
                await tokensMemStorage.set(TOK_KEY, EXPIRED_REFRESH)
                result = null
              } else {
                msg += ` refresh NOT expired (${refreshExpiredWithTimeBuffer.toFixed(0)})`
              }
            } else {
              msg += red('refrRes:') + JSON.stringify(refrRes)
            } // if (refrRes.newToken)
          } // if tokensMemStorage.has(TOK_KEY)

@hichemfantar
Copy link

Is jwt refresh token support present or not?

@LaurenceGA
Copy link

Is jwt refresh token support present or not?

Well, it seems, with a bit of a workaround, yes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working triage Unseen or unconfirmed by a maintainer yet. Provide extra information in the meantime.
Projects
None yet
Development

No branches or pull requests