Skip to content

Tokens rotation does not persist the new token #7558

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
mrbodich opened this issue May 15, 2023 · 67 comments
Open

Tokens rotation does not persist the new token #7558

mrbodich opened this issue May 15, 2023 · 67 comments
Labels
triage Unseen or unconfirmed by a maintainer yet. Provide extra information in the meantime.

Comments

@mrbodich
Copy link

mrbodich commented May 15, 2023

Environment

When rotating tokens, new token is not stored and thus not reused, so token is lost. The old token still persists instead and used for all further iterations of current session.
Only initial token generated on login works and reused constantly.

I use Keycloak as the external IDP
Keycloak — 21.1.1
Nextjs — 13.4.2
Next-auth — 4.22.1
Node — 16.2.0, 19.9.0

Reproduction URL

https://github.com/mrbodich/next-auth-example-fork.git

Describe the issue

When I use async jwt() function in callbacks section, I get the new token from external IDP successfully, create the new token object and return in async jwt() just like documentation says.

Here is my piece of code in the last else block (if access token is expired)

} else {
  // If the access token has expired, try to refresh it
  console.log(`Old token expired: ${token.expires_at}`)
  const newToken = await refreshAccessToken(token)
  console.log(`New token acquired: ${newToken.expires_at}`)
  return newToken
}

Once token expired, and else block is executed, I have constantly updating at each request. Here is what I get in the console logged:

Old token expired: 1684147058
Token was refreshed. New token expires in 60 sec at 1684147125, refresh token expires in 2592000 sec
New token acquired: 1684147125

Old token expired: 1684147058
Token was refreshed. New token expires in 60 sec at 1684147128, refresh token expires in 2592000 sec
New token acquired: 1684147128

Old token expired: 1684147058
Token was refreshed. New token expires in 60 sec at 1684147132, refresh token expires in 2592000 sec
New token acquired: 1684147132

As you see, 1684147058 is not changed between requests, so new JWT is just lost somewhere and not used for later requests. Though at the first login, returned jwt is used correctly.

How to reproduce

  1. Clone this repo https://github.com/mrbodich/next-auth-example-fork.git
  2. Transfer .env.local.example file to .env.local file
  3. When signing in, use credentials from .env.local.example file, row 13
  4. After sign-in, token will start refreshing after 1 minute (token lifespan set in Keycloak)
  5. Look in the console for next-auth logs

⚠️ Try to comment lines 18 ... 25 in the index.tsx file (getServerSideProps function), and tokens will start rotating fine.

Expected behavior

Token returned in the async jwt() function in callbacks section must be used on the next request and not being lost.

@mrbodich mrbodich added the triage Unseen or unconfirmed by a maintainer yet. Provide extra information in the meantime. label May 15, 2023
@osmandvc
Copy link

similiar problem is stated in #6642 . Sadly it seems like noone cares about this issue atm, altough its a system breaking problem.

@balazsorban44 balazsorban44 added the incomplete Insufficient reproduction. Without more info, we won't take further actions/provide help. label May 16, 2023
@github-actions
Copy link

We cannot recreate the issue with the provided information. Please add a reproduction in order for us to be able to investigate.

Why was this issue marked with the incomplete label?

To be able to investigate, we need access to a reproduction to identify what triggered the issue. We prefer a link to a public GitHub repository (template), but you can also use a tool like CodeSandbox or StackBlitz.

To make sure the issue is resolved as quickly as possible, please make sure that the reproduction is as minimal as possible. This means that you should remove unnecessary code, files, and dependencies that do not contribute to the issue.

Please test your reproduction against the latest version of NextAuth.js (next-auth@latest) to make sure your issue has not already been fixed.

I added a link, why was it still marked?

Ensure the link is pointing to a codebase that is accessible (e.g. not a private repository). "example.com", "n/a", "will add later", etc. are not acceptable links -- we need to see a public codebase. See the above section for accepted links.

What happens if I don't provide a sufficient minimal reproduction?

Issues with the incomplete label that receives no meaningful activity (e.g. new comments with a reproduction link) are closed after 7 days.

If your issue has not been resolved in that time and it has been closed/locked, please open a new issue with the required reproduction. (It's less likely that we check back on already closed issues.)

I did not open this issue, but it is relevant to me, what can I do to help?

Anyone experiencing the same issue is welcome to provide a minimal reproduction following the above steps. Furthermore, you can upvote the issue using the 👍 reaction on the topmost comment (please do not comment "I have the same issue" without repro steps). Then, we can sort issues by votes to prioritize.

I think my reproduction is good enough, why aren't you looking into it quicker?

We look into every NextAuth.js issue and constantly monitor open issues for new comments.

However, sometimes we might miss one or two. We apologize, and kindly ask you to refrain from tagging core maintainers, as that will usually not result in increased priority.

Upvoting issues to show your interest will help us prioritize and address them as quickly as possible. That said, every issue is important to us, and if an issue gets closed by accident, we encourage you to open a new one linking to the old issue and we will look into it.

Useful Resources

@mrbodich
Copy link
Author

mrbodich commented May 16, 2023

Hello @balazsorban44. I've deployed Keycloak, made necessary setup and pushed my example based on the latest example repo fork to github.
https://github.com/mrbodich/next-auth-example-fork.git

Alternatively, I've updated the main question, section Reproduction URL and How to reproduce

You can find the credentials to login in the .env.local.example file, along with other necessary env variables, so just transfer this file to the .env.local file.
I've set token lifespan to 1 minute, so it's the time you should wait before token will want to refresh

Updated issue description

I've found more details.
Token rotation worked fine when using client-side session request.
Once I've configured server-side session passing to props, refreshed tokens stopped persisting.

Look at this file in my repo. Tokens are rotating fine if you will comment lines 18 ... 25
index.tsx

//Token is not persisting when using server side session
export const getServerSideProps: GetServerSideProps = async (context) => {
  const session = await getSession(context)
  return {
      props: {
          session
      }
  }
}

@marysmech
Copy link

similiar problem is stated in #6642 . Sadly it seems like noone cares about this issue atm, altough its a system breaking problem.

Yet not quite similar, since this issue refers to the older next-auth package, not the new @auth. Furthermore, that discussion relates to the database strategy, not JWT.

@Mikk36 I disagree. As the author of referred discussion the problem is described with JWT tokens and not database strategy and problem seems to remain also we newer versions (its true that i haven't tested with latest version).

@balazsorban44 balazsorban44 removed the incomplete Insufficient reproduction. Without more info, we won't take further actions/provide help. label May 16, 2023
@Mikk36
Copy link

Mikk36 commented May 16, 2023

similiar problem is stated in #6642 . Sadly it seems like noone cares about this issue atm, altough its a system breaking problem.

Yet not quite similar, since this issue refers to the older next-auth package, not the new @auth. Furthermore, that discussion relates to the database strategy, not JWT.

@Mikk36 I disagree. As the author of referred discussion the problem is described with JWT tokens and not database strategy and problem seems to remain also we newer versions (its true that i haven't tested with latest version).

My bad, I mixed up discussion numbers and thought it was something else.

@anampartho
Copy link
Contributor

@mrbodich Instead of using getSession inside getServerSideProps, please use getServerSession as stated here. This persists the refresh token.

// index.tsx
import { authOptions } from '@/pages/api/auth/[...nextauth]'

export const getServerSideProps: GetServerSideProps = async (context) => {
  const session = await getServerSession(context.req, context.res, authOptions)
  return {
      props: {
          session
      }
  }
}

@mrbodich
Copy link
Author

mrbodich commented May 25, 2023

@mrbodich Instead of using getSession inside getServerSideProps, please use getServerSession as stated here. This persists the refresh token.

// index.tsx
import { authOptions } from '@/pages/api/auth/[...nextauth]'

export const getServerSideProps: GetServerSideProps = async (context) => {
  const session = await getServerSession(context.req, context.res, authOptions)
  return {
      props: {
          session
      }
  }
}

I have tried this and getting such error on backend
Error: Error serializing .session.user.image returned from getServerSideProps in "/".
Can you try my attached example project and make your changes in the index.tsx file please? It's already configured for the remote IDP so just rename .env.local.example file to .env.local, username and password just mentioned in this env file too.

@anampartho
Copy link
Contributor

@mrbodich That is not related to getServerSession. You need to properly set the user object inside jwt callback to make sure that no key inside the user object is undefined. It either has to have a value or has to be null.

Please check PR - mrbodich/next-auth-example-fork#1 for detailed code.

@mrbodich
Copy link
Author

mrbodich commented May 25, 2023

@mrbodich That is not related to getServerSession. You need to properly set the user object inside jwt callback to make sure that no key inside the user object is undefined. It either has to have a value or has to be null.

Please check PR - mrbodich/next-auth-example-fork#1 for detailed code.

Thank you @anampartho, I got the idea now. Just was confused why it worked without server session handling.

Can I ask you to give me a very important tip that I can't understand please? How can I use getServerSession not only in the root '/' route (or on each route separately), but on the very top level so all routes will inherit that? And how is it possible to add extra properties on the lower levels or sub-routes?

Adding getServerSession to _app.tsx does not work.

@mrbodich
Copy link
Author

mrbodich commented May 25, 2023

@mrbodich That is not related to getServerSession. You need to properly set the user object inside jwt callback to make sure that no key inside the user object is undefined. It either has to have a value or has to be null.

Please check PR - mrbodich/next-auth-example-fork#1 for detailed code.

By the way, I came up with this solution. Updated provider's profile method a bit with image: profile.picture ?? null, just added the optional chaining for the image property:

const keycloak = KeycloakProvider({
    clientId: process.env.KEYCLOAK_ID,
    clientSecret: process.env.KEYCLOAK_SECRET,
    issuer: process.env.KEYCLOAK_ISSUER,
    authorization: { params: { scope: "openid email profile offline_access" } },
    tokenUrl: 'protocol/openid-connect/token',
    profile(profile, tokens) {
        return {
            id: profile.sub,
            name: profile.name ?? profile.preferred_username,
            email: profile.email,
            image: profile.picture ?? null,
        }
    },
});

PS: Thank you so much for your help.

@anampartho
Copy link
Contributor

@mrbodich That is not related to getServerSession. You need to properly set the user object inside jwt callback to make sure that no key inside the user object is undefined. It either has to have a value or has to be null.
Please check PR - mrbodich/next-auth-example-fork#1 for detailed code.

Thank you @anampartho, I got the idea now. Just was confused why it worked without server session handling.

Can I ask you to give me a very important tip that I can't understand please? How can I use getServerSession not only in the root '/' route (or on each route separately), but on the very top level so all routes will inherit that? And how is it possible to add extra properties on the lower levels or sub-routes?

Adding getServerSession to _app.tsx does not work.

@mrbodich Unfortunately, you have to use getServerSession on each routes getServerSideProps. There is no way to use it on / and make the session available on all routes.

@osmandvc
Copy link

osmandvc commented May 26, 2023

I am using getServerSession in the app directory, but the problem stil occurs. But I guess the problem is because of the following issue stated by this user: #6642 (comment)

@thexpand
Copy link

thexpand commented Jun 4, 2023

I am using getServerSession in the app directory, but the problem stil occurs. But I guess the problem is because of the following issue stated by this user: #6642 (comment)

@osmandvc Do you have a workaround for that?

@osmandvc
Copy link

osmandvc commented Jun 7, 2023

I am using getServerSession in the app directory, but the problem stil occurs. But I guess the problem is because of the following issue stated by this user: #6642 (comment)

@osmandvc Do you have a workaround for that?

Sadly I did not find a really convenient way without too much overhead to make it work with RSC. The only solution currently seems like to switch to traditional client-side Authentication with useSession and a SessionProvider.

@israelvcb
Copy link

israelvcb commented Jun 19, 2023

I am using getServerSession in the app directory, but the problem stil occurs. But I guess the problem is because of the following issue stated by this user: #6642 (comment)

@osmandvc Do you have a workaround for that?

Sadly I did not find a really convenient way without too much overhead to make it work with RSC. The only solution currently seems like to switch to traditional client-side Authentication with useSession and a SessionProvider.

Could you give me an example?

@arminhupka
Copy link

arminhupka commented Jun 20, 2023

Any progress with this bug? I cannot implement token refreshing with next auth. After login i getting new set of tokens and first refreshing is ok but when i get new set the old tokens are not updated and next refresh call gives me error.

image

@ampled
Copy link

ampled commented Jun 21, 2023

I also have this problem with @auth/core 0.8.2 and @auth/sveltekit 0.3.3 using a custom provider for Azure B2C.

Since sveltekit does prefetching when hovering links it triggers an awful lot of "token refreshes" after the first access token expires.

@israelvcb
Copy link

israelvcb commented Jun 21, 2023

Any progress with this bug? I cannot implement token refreshing with next auth. After login i getting new set of tokens and first refreshing is ok but when i get new set the old tokens are not updated and next refresh call gives me error.

image

I use keycloack and I manage to make the token refresh a few seconds before it expires but the getsesion still gets the old token but when I refresh the browser tab with f5 it gets the refreshed token, I am trying to do something to observe this change, like a useeffect.

import NextAuth, { KeycloakTokenSet, NextAuthOptions } from "next-auth";
import { JWT } from "next-auth/jwt";
import KeycloakProvider from "next-auth/providers/keycloak";

const keycloak = KeycloakProvider({
  clientId: process.env.KEYCLOAK_ID,
  clientSecret: process.env.KEYCLOAK_SECRET,
  issuer: process.env.KEYCLOAK_ISSUER,
  authorization: { params: { scope: "openid email profile offline_access" } },
});

async function doFinalSignoutHandshake(token: JWT) {
  if (token.provider == keycloak.id) {
    try {
      const issuerUrl = keycloak.options!.issuer!;
      const logOutUrl = new URL(`${issuerUrl}/protocol/openid-connect/logout`);
      logOutUrl.searchParams.set("id_token_hint", token.id_token);
      const { status, statusText } = await fetch(logOutUrl);
      console.log("Completed post-logout handshake", status, statusText);
    } catch (e: any) {
      console.error("Unable to perform post-logout handshake", e?.code || e);
    }
  }
}

function parseJwt(token: string) {
  return JSON.parse(Buffer.from(token.split(".")[1], "base64").toString());
}

async function refreshAccessToken(token: JWT): Promise<JWT> {
  try {
    // We need the `token_endpoint`.
    const response = await fetch(
      `${keycloak.options!.issuer}/protocol/openid-connect/token`,
      {
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: new URLSearchParams({
          client_id: keycloak.options!.clientId,
          client_secret: keycloak.options!.clientSecret,
          grant_type: "refresh_token",
          refresh_token: token.refresh_token,
        }),
        method: "POST",
      }
    );

    const tokensRaw = await response.json();
    const tokens: KeycloakTokenSet = tokensRaw;
    // console.log(tokensRaw)

    if (!response.ok) throw tokens;

    const expiresAt = Math.floor(Date.now() / 1000 + tokens.expires_in);
    console.log(
      `Token was refreshed. New token expires in ${tokens.expires_in} sec at ${expiresAt}, refresh token expires in ${tokens.refresh_expires_in} sec`
    );

    const newToken: JWT = {
      ...token,
      access_token: tokens.access_token,
      refresh_token: tokens.refresh_token,
      id_token: tokens.id_token,
      expires_at: expiresAt,
      provider: keycloak.id,
    };
    return newToken;
  } catch (error) {
    // console.error("Error refreshing access token: ", error)
    console.error("Error refreshing access token: ");
    throw error;
  }
}

// For more information on each option (and a full list of options) go to https://next-auth.js.org/configuration/options
export const authOptions: NextAuthOptions = {
  secret: process.env.NEXTAUTH_SECRET,
  // https://next-auth.js.org/configuration/providers/oauth
  providers: [keycloak],
  theme: {
    colorScheme: "light",
  },

  pages: {
    signIn: "/login",
    signOut: "/login",
  },
  callbacks: {
    async jwt({ token, account, user }) {
      console.log("Executing jwt()");
      if (account && user) {
        const jwtDecoded = parseJwt(account.access_token as string);

        if (!account.access_token)
          throw Error("Auth Provider missing access token");
        if (!account.refresh_token)
          throw Error("Auth Provider missing refresh token");
        if (!account.id_token) throw Error("Auth Provider missing ID token");
        // Save the access token and refresh token in the JWT on the initial login

        const newToken: JWT = {
          ...token,
          access_token: account.access_token,
          refresh_token: account.refresh_token,
          id_token: account.id_token,
          expires_at: Math.floor(account.expires_at ?? 0),
          provider: account.provider,
          userName: jwtDecoded.preferred_username,
          userRoles: jwtDecoded.resource_access.account.roles,
        };
        return newToken;
      }
      const timeRemaining = token.expires_at * 1000 - Date.now();

      if (timeRemaining > 30000) {
        // If the token's remaining time is greater than 30 seconds, return the current token
        console.log(
          `\n>>> ${timeRemaining / 1000} seconds left until token expires`
        );
        return token;
      }
      console.log(`\n>>> Old token expired`);

      // If the access token has expired or will expire within 30 seconds, try to refresh it
      const newToken = await refreshAccessToken(token);

      console.log(`New token adquired: ${newToken.expires_at}`);

      return token;
    },
    async session({ session, token }) {
      console.log(`Executing session() with token ${token.expires_at}`);
      // You need to set the user object properly in jwt callback,
      // Error: Error serializing .session.user.image was occuring because
      // session.user.image was undefined, it needs to be value || null
      session.user = { ...token };
      return { ...session };
    },
  },

  events: {
    signOut: async ({ session, token }) => doFinalSignoutHandshake(token),
  },
  jwt: {
    // maxAge: 60, // 20 horas
    maxAge: 32400, // 9h
  },
  session: {
    // maxAge: 30 * 24 * 60 * 60, // 30 days : 2592000, same as in Keycloak
    maxAge: 32400, // 9h
  },
};

export default NextAuth(authOptions);

API:

import { NODE_ENV, uri } from "@/constants/environment-variables";
import axios, { AxiosInstance, AxiosRequestConfig } from "axios";

const axiosInstance = axios.create({
  baseURL: uri[NODE_ENV],
});


async function getData() {
  const res = await axios.get("/api/auth/session");
  return res;
}

const setAuthorizationHeader = async (axiosInstance: AxiosInstance) => {
  // You can try to get the access token that way
  const session = await getData();

  // Or you can try to use Next Auth function
  // const session = getSession()

 // if I'm on the /api/auth/session page and I press f5 to refresh the tab, the new access token is there,
 // however, neither getData() nor getSession() remains the previous access token, they will only update,
 // if I refresh the page. I believe that useeffect solves it but I still don't know how to do it

  console.log(session);
  if (session) {
    const token = session.data.user.access_token;
    console.log(session);
    axiosInstance.interceptors.request.use((config) => {
      config.headers.Authorization = `Bearer ${token}`;
      return config;
    });
  }
};

setAuthorizationHeader(axiosInstance);

const api = (axios: AxiosInstance) => {
  return {
    get: function <T>(url: string, config: AxiosRequestConfig = {}) {
      return axios.get<T>(url, config);
    },
    put: function <T>(
      url: string,
      body: unknown,
      config: AxiosRequestConfig = {}
    ) {
      return axios.put<T>(url, body, config);
    },
    post: function <T>(
      url: string,
      body: unknown,
      config: AxiosRequestConfig = {}
    ) {
      return axios.post<T>(url, body, config);
    },
    delete: function <T>(url: string, config: AxiosRequestConfig = {}) {
      return axios.delete<T>(url, config);
    },
  };
};

export default api(axiosInstance);

@osmandvc
Copy link

I am using getServerSession in the app directory, but the problem stil occurs. But I guess the problem is because of the following issue stated by this user: #6642 (comment)

@osmandvc Do you have a workaround for that?

Sadly I did not find a really convenient way without too much overhead to make it work with RSC. The only solution currently seems like to switch to traditional client-side Authentication with useSession and a SessionProvider.

Could you give me an example?

I did it similar to this comment: #5647 (comment) . Basically follow the Tutorial on the Nextauth-Page, the only thing that changes is where you put your SessionProvider. Make a seperate Client-Component put your Provider there, and wrap your Content in your Root-Layout with the newly created Client-Component. This way you make sure that the layout.tsx remains a Server-Component. Now every underlying page has Access to the Session-Object (mostly with delay, because client-side)

@jazerix
Copy link

jazerix commented Jul 29, 2023

As of [email protected] (experimental), this is still an occurring issue. The initial token is stored; however, going forward, updates made to it are never saved (at least not to the token that is provided within jwt callback). As such, auth.js will attempt to refresh the token since it's always checking against the first expires_at timestamp.

Due to this issue, refresh token rotation is in practice, not possible with auth.js :(


Edit 1:
It's pretty evident that the next-auth.session-token cookie is not updated whenever it has been created initially, despite returning new a new object within the jwt callback. The difference seems to be that the first time around, the flow is triggered within the core/callback.js whereas subsequent requests are handled by the core/session.js file.


Edit 2:
After some further investigation, the first request works as intended since auth.js is in control of the redirection flow, ie. when you go through an OAuth login procedure. At this point they are able to update the next-auth.session-token cookie and reflect the changes made within the jwt callback.

However, when using something like getServerSession(...) method that invokes the jwt callback, the new cookie is in actuality added to the response as intended. However, next is controlling the request, and strips the set-cookie header since it's being run from a normal page and not an API, this notion is further supported in #7522.

As such, there is much that can be done currently, until Next makes it possible to set cookies sitewide.


Edit 3:
This is likewise an issue within Sveltekit (#6447) and, unfortunately, not isolated to Next. The fact that auth.js doesn't work with both of these frameworks indicates (to me) that the refresh rotation functionality is currently broken. I think it would at least make sense to make this very apparent on the guide page that their example currently is not functional.

@PierfrancescoSoffritti
Copy link

Hi @balazsorban44 , refresh token rotation seems to be broken. Do you know if there is a plan to fix this issue? Is someone looking into it?

@walshhub
Copy link

Have a look at the issue here: #4229. Managed to get it work using update() from the useSession hook.

@aliyss
Copy link
Contributor

aliyss commented Aug 17, 2023

It seems, that token rotation actually theoretically works when using auth.js
In all my cases auth.js returns a valid 'set-cookie' header as a response.

When using token rotation after a login I noticed, that after a redirect it calls Auth again on /api/auth/session.

I am using qwik-auth so I can only compare to that. It seems, that qwik-auth makes an exception when manually calling /api/auth/session and does not set the 'set-cookie' after a successful response.

Following I will describe what happens in qwik-auth, but I assume that the issue is similar with other implementations.

The issue is, that the updated cookie is not received by the client, since qwik-auth works like this:

Context:
Reference: https://github.com/BuilderIO/qwik/blob/47c2d1e838e9f748b191e983dabb0bac476f8083/packages/qwik-auth/src/index.ts#L18

const actions: AuthAction[] = [
  'providers',
  'session',
  'csrf',
  'signin',
  'signout',
  'callback',
  'verify-request',
  'error',
];

onRequest:
Reference: https://github.com/BuilderIO/qwik/blob/47c2d1e838e9f748b191e983dabb0bac476f8083/packages/qwik-auth/src/index.ts#L90

  const onRequest = async (req: RequestEvent) => {
    if (isServer) {
      const prefix: string = '/api/auth';

      const action = req.url.pathname.slice(prefix.length + 1).split('/')[0] as AuthAction;

      const auth = await authOptions(req);
      
      // We notice, that there is no action that is named like so and neither is the prefix present.
      if (actions.includes(action) && req.url.pathname.startsWith(prefix + '/')) {
        const res = await Auth(req.request, auth);
        const cookie = res.headers.get('set-cookie');
        if (cookie) {
          req.headers.set('set-cookie', cookie);
          res.headers.delete('set-cookie');
          fixCookies(req);
        }
        throw req.send(res);
      } else {
        // So this gets triggered and getSessionData(...) does not set the cookies on response.
        req.sharedMap.set('session', await getSessionData(req.request, auth));
      }
    }
  };

The fix I did is here: https://github.com/BuilderIO/qwik/pull/4960/files

@aliyss
Copy link
Contributor

aliyss commented Aug 20, 2023

Ok so I took some time just looking at the react code, but since I don't use react I cannot confirm.

Hopefully somebody can confirm this for me:

1. Get Session gets called:

  • broadcast.post triggers fetchData
    export async function getSession(params?: GetSessionParams) {
    const session = await fetchData<Session>(
    "session",
    __NEXTAUTH,
    logger,
    params
    )
    if (params?.broadcast ?? true) {
    broadcast.post({ event: "session", data: { trigger: "getSession" } })
    }
    return session
    }

2. fetchData gets called:

  • fetchData only returns res.json()
    export async function fetchData<T = any>(
    path: string,
    __NEXTAUTH: AuthClientConfig,
    logger: LoggerInstance,
    { ctx, req = ctx?.req }: CtxOrReq = {}
    ): Promise<T | null> {
    const url = `${apiBaseUrl(__NEXTAUTH)}/${path}`
    try {
    const options: RequestInit = {
    headers: {
    "Content-Type": "application/json",
    ...(req?.headers?.cookie ? { cookie: req.headers.cookie } : {}),
    },
    }
    if (req?.body) {
    options.body = JSON.stringify(req.body)
    options.method = "POST"
    }
    const res = await fetch(url, options)
    const data = await res.json()
    if (!res.ok) throw data
    return Object.keys(data).length > 0 ? data : null // Return null if data empty
    } catch (error) {
    logger.error("CLIENT_FETCH_ERROR", { error: error as Error, url })
    return null
    }
    }

If fetchData works like in qwik (Reference) then we must return cookies as well, since /api/session/ returns Set-Cookie Headers when using jwt rotation.

Probably triggered here:

response.cookies?.push(...sessionCookies)

@Cikmo
Copy link

Cikmo commented Aug 20, 2023

So what's needed is a way to have the cookie being pushed on any request, not just /session

@rinvii
Copy link

rinvii commented Aug 23, 2023

Sounds like you can just use a middleware. On every request initiated in protected routes, fetch to /api/session and set the cookies in the middleware response.

@aliyss
Copy link
Contributor

aliyss commented Aug 23, 2023

@rinvii ofc that would work, but then you also have to call signin manually since after signin the jwt rotation gets triggered again since session is called and at that point you are building your own implementation of /next-auth/src/react/index.ts

I am assuming that react/index.ts is the same like https://github.com/BuilderIO/qwik/blob/47c2d1e838e9f748b191e983dabb0bac476f8083/packages/qwik-auth/src/index.ts

// Disclaimer:
The pull request for qwik got merged, so I assume that it is correct there.
I do not know react. This is all just guess work based on looking at similar files.

@jarosik10
Copy link

I'm using "next": "14.1.3" and "next-auth": "4.24.7" and I have the same bug.

I've managed to solve this problem with wrapping the root layout with SessionProvider. I have no idea why, but adding the SessionProvider makes the jwt callback to receive token with updated state.

"use client";
import { SessionProvider } from "next-auth/react";
import React, { ReactNode } from "react";

export const SessionProviderWrapper = ({
  children,
}: {
  children?: ReactNode;
}) => {
  return <SessionProvider>{children}</SessionProvider>;
};
import { getServerSession } from "next-auth/next";

import { Nav } from "@/app/ui/Nav/Nav";
import { SessionProviderWrapper } from "@/app/lib/Providers";
import { authOptions } from "@/auth";

export default async function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  const session = await getServerSession(authOptions);
  return (
    <SessionProviderWrapper>
        <html lang="en">
          <body>
            <Nav isLoggedIn={!!session} />
            {children}
          </body>
        </html>
    </SessionProviderWrapper>
  );
}

@wodka
Copy link

wodka commented Mar 26, 2024

This is also happening with nextjs 14 and the new authjs 5 beta - using only the client side it will work, but whenever the token is refreshed through the middleware it is not updated to the client! The next request then still uses the old session.

@wonkyDD
Copy link

wonkyDD commented Mar 26, 2024

Because of this, I gave up on using next auth.
Implementing httpOnly and CSRF defense directly with server is much more intuitive and convenient.

@HenrikZabel
Copy link

This is still a problem. There is no way to update the session on the server side

@NanningR
Copy link

@HenrikZabel I literally mentioned it here: #7558 (comment)

And it is discussed in detail here: #9715

@jarosik10 @wodka @wonkyDD The point is that you don't ever need things like SessionProvider when using next-auth and the middleware trick to update the session on server side. In fact, when using Next.js server components, you should completely avoid any client-side functions such as useSession(). You only focus on the server side, refresh the tokens there, and pass the session from server components down to client-components as props:

import { getAuthSession } from "@/lib/auth/AuthOptions";
const session: Session | null = await getAuthSession();

// AuthOptions.ts
export const getAuthSession = async (): Promise<Session | null> => await getServerSession(authOptions);

I can guarantee you that this works when implemented correctly. This method is actually quite in line with Next.js's official documentation on authentication: https://nextjs.org/docs/pages/building-your-application/authentication. It's just crazy that we have to figure out the best way to do this ourselves.

@HenrikZabel
Copy link

@NanningR I am using v5. I thought about just manually calling unstable_update. This would work but it is not documented anywhere what exactly this does and it also seems unstable (as the name suggests)

@jarosik10
Copy link

@NanningR next-auth docs (which are not great) shows that refresh token rotation should be handled inside jwt callback delcared in next-auth configuration. https://authjs.dev/guides/refresh-token-rotation

I haven't tried to handle the rotation inside middleware yet. But yeah, i agree that looking for the right approach (with poor docs) is kinda annoying.

@NanningR
Copy link

@jarosik10 Refreshing in the callbacks does not work (yet) because of how Next.js with the app router works; the cookies() update() method can't set cookies directly on the server side. That is why the solution is to implement the refresh token rotation logic in middleware.ts, where requests and responses can be intercepted. When refreshing the tokens in the middleware, you need to use the same token form factor as in your callbacks (explained here: #9715 (reply in thread) and here: #9715 (reply in thread)).

The 'official' docs are hopelessly unclear, which is also the case for the page you just sent me, even though it got updated recently. Before #9715 existed, we were discussing here: #8254. It then got converted into the new discussion by the official maintainer:

afbeelding

For some reason, they refuse to be clear in the docs. I think they have just not found a clean way to implement this for server components, so they are silent about the issue.

I recommend you try the middleware solution. It is a bit of a hassle to set up, but works flawlessly once implemented. In any case, if you refuse to use this solution, you can always go for the database strategy instead of the JWT strategy. When saved in a database, the session can be updated however and whenever you like. Hope this helps :)

@HenrikZabel I'm not using v5 myself yet because it is still in beta and as such not ready for production. However, a bunch of people in the discussion I linked to have successfully implemented this using the new v5.

@aakash14goplani
Copy link

People from SvelteKit ecosystem, here is how to rotate the token - https://blog.aakashgoplani.in/how-to-implement-refresh-token-rotation-in-sveltekitauth

@BenParr64
Copy link

BenParr64 commented Jul 14, 2024 via email

@pdegiglio
Copy link

This is based on a problem with race conditions. the jwt call-back in a non-trivial app, also using middleware, the JWT that is being returned is not used by subsequent calls as it still has the original jwt in access.
What is possible here? any solutions?

@wave-m
Copy link

wave-m commented Aug 27, 2024

Any updates on this issue?

Edit by maintainer bot: Comment was automatically minimized because it was considered unhelpful. (If you think this was by mistake, let us know). Please only comment if it adds context to the issue. If you want to express that you have the same problem, use the upvote 👍 on the issue description or subscribe to the issue for updates. Thanks!

@kitadakyou
Copy link

The same problem was also observed in 5.0.0-beta.22.
Also, update_unstble does not seem to be working. Maybe for the same reason.

@DaviReisVieira
Copy link

I have th same issue at beta.25. Some update?

@joacodf
Copy link

joacodf commented Jan 10, 2025

Hello, some update? I have the issue

@huboh
Copy link

huboh commented Feb 8, 2025

This is February 2025, and this issue still hasn't been resolved. Even the suggested middleware workarounds aren't working. Any updates would be greatly appreciated!

cc @balazsorban44 @ThangHuuVu @ndom91

@david-lagrange
Copy link

david-lagrange commented Mar 7, 2025

Hey all, after about a million different attempts to get this to work for my apps, I have finally come to a solution that mimics pretty much exactly what we would want in refresh token behavior, works with the latest NextAuth v5, keeps all token/session manipulation in the NextAuth control/ecosystem, keeps everything server side, and allows for manual refresh!

This basically just triggers a background sign-in process which, wait for it.... actual updates the session. I've simply repurposed the initial authorize function to do what it seems to do best, actual get in there and update the damn session.

The check is for 5 minutes before but change as needed. Perhaps you keep your main token valid for 7 days and refresh within 3 days of expiration, or what ever your use case is. Choose your window and change the time check.

Send "?refreshToken=true" on any request you would like to trigger a manual refresh. (role/permission updates, subscription changes, etc..). It will remove that param before passing it onto the final redirect keeping intact any other search parameters that may have been on the original request path.

I have tested this also in the case that a user tries to access a role protected route. Redirects protecting that role based route still trigger even though the refresh technically redirects to any URL. One potential option to full proof that would be to move it below redirect logic.

Right now my backend denies fully expired tokens, but if your backend does not validate token lifetime you can remove the redirect to a logout page so it will always get new valid tokens.

I have generated documentation for the workaround which is below. You should be able to copy and paste this into cursor and it have a pretty good idea of what it needs to implement based on your current implementation.

Workaround for NextAuth v5 JWT Refresh Token Bug in Next.js


Introduction

NextAuth v5 has a well-documented bug where refreshed JWT access tokens fail to persist in the session when using the JWT strategy. Specifically, after a refresh token is used to obtain a new access token within the jwt callback, the session retains the original token data, causing the application to reuse outdated tokens. This can lead to repeated refresh attempts, authentication failures, or unexpected logouts, particularly in Next.js applications using server-side rendering (SSR) or the App Router. This issue has been reported extensively in community forums and GitHub issues (e.g., #7558, #6642, #7522), yet remains unresolved as of February 2025.

This documentation presents a practical workaround that keeps refresh logic within the NextAuth ecosystem, avoiding the need to manually craft session tokens. By modifying the sign-in process to support authentication with either email/password or access/refresh tokens, this solution enables the system to "re-sign in" the user when a token refresh is needed. This updates the session with new token values securely on the server side, ensuring the refresh token remains protected and never exposed to the client. Below, we outline the implementation steps, explain each component, and highlight the benefits of this approach.


Solution Overview

The workaround leverages NextAuth's CredentialsProvider to handle two authentication flows: initial login with email and password, and token refresh with existing access and refresh tokens. When the access token nears expiration or a manual refresh is requested, the system retrieves the current tokens and uses them to re-authenticate the user via the signIn function. This process updates the session with the new tokens, bypassing the persistence issue in the jwt callback. The solution involves four key components:

  1. Modified auth.ts: Configures the CredentialsProvider to accept both login types and defines helper functions for token refresh and user extraction.
  2. Token Expiry Logic in auth.config.ts: Detects when a token is about to expire or a refresh is requested, triggering a redirect to a refresh route.
  3. Refresh Route (app/api/auth/refresh/route.ts): Executes the token refresh by calling signIn with the current tokens and redirects the user back to their original page.
  4. Middleware (middleware.ts): Protects routes and integrates the authentication logic using NextAuth's auth function.

This approach ensures seamless token management while maintaining security and simplicity.


Implementation Steps

Below are the detailed steps to implement this workaround in your Next.js application using NextAuth v5. Each section includes a concise explanation and a generic code snippet tailored for broad applicability.

1. Modify auth.ts

Purpose: Configures NextAuth to handle both initial logins and token refreshes through the CredentialsProvider.

Key Changes:

  • Add accessToken and refreshToken as optional credentials alongside email and password.
  • Implement an authorize function that processes either login type:
    • For email/password, it calls your backend login endpoint.
    • For access/refresh tokens, it refreshes the tokens via your backend.
  • Define helper functions to refresh tokens and extract user data from JWTs.
  • Use jwt and session callbacks to populate token and session data, preserving the backendExp for expiration tracking.

Code:

// auth.ts
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { z } from "zod";
import { authConfig } from "./auth.config";
import { JWT } from "next-auth/jwt";
import { DefaultSession } from "next-auth";

const API_URL = process.env.NEXT_PUBLIC_API_URL || "https://your-backend-api.com";

// Generic fetch options helper
const createFetchOptions = (body: any) => ({
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Accept": "application/json",
  },
  body: JSON.stringify(body),
});

// Refresh token function
const refreshAccessToken = async (
  refreshToken: string,
  accessToken: string
): Promise<{ accessToken: string; refreshToken: string; exp: number } | null> => {
  try {
    const response = await fetch(`${API_URL}/auth/refresh`, createFetchOptions({ refreshToken, accessToken }));
    if (!response.ok) throw new Error("Failed to refresh token");
    const refreshedTokens = await response.json();
    const [, payloadBase64] = refreshedTokens.accessToken.split(".");
    const payload = JSON.parse(Buffer.from(payloadBase64, "base64").toString());
    return {
      accessToken: refreshedTokens.accessToken,
      refreshToken: refreshedTokens.refreshToken,
      exp: payload.exp,
    };
  } catch (error) {
    console.error("Token refresh failed:", error);
    return null;
  }
};

// Extract user data from JWT
const extractUserFromToken = (accessToken: string, additionalData: any = {}) => {
  const [, payloadBase64] = accessToken.split(".");
  const payload = JSON.parse(Buffer.from(payloadBase64, "base64").toString());
  return {
    id: payload.sub || payload.userId,
    email: payload.email,
    name: payload.name || payload.username,
    accessToken,
    exp: additionalData.exp || payload.exp,
    refreshToken: additionalData.refreshToken,
  };
};

// Extend NextAuth types
declare module "next-auth" {
  interface Session extends DefaultSession {
    accessToken?: string;
    expires: string;
    user?: {
      id?: string;
      email?: string;
      name?: string;
    } & DefaultSession["user"];
  }
  interface User {
    accessToken?: string;
    refreshToken?: string;
    exp?: number;
    backendExp?: number;
  }
}

declare module "next-auth/jwt" {
  interface JWT {
    refreshToken?: string;
    accessToken?: string;
    exp?: number;
    backendExp?: number;
  }
}

const handler = NextAuth({
  ...authConfig,
  providers: [
    Credentials({
      credentials: {
        email: { label: "Email", type: "text" },
        password: { label: "Password", type: "password" },
        accessToken: { label: "Access Token", type: "text" },
        refreshToken: { label: "Refresh Token", type: "text" },
      },
      async authorize(credentials) {
        if (!credentials) return null;

        // Token refresh flow
        if (credentials.accessToken && credentials.refreshToken) {
          const refreshedTokens = await refreshAccessToken(credentials.refreshToken, credentials.accessToken);
          if (!refreshedTokens) return null;
          return extractUserFromToken(refreshedTokens.accessToken, {
            refreshToken: refreshedTokens.refreshToken,
            exp: refreshedTokens.exp,
          });
        }

        // Email/password login flow
        const parsedCredentials = z
          .object({ email: z.string(), password: z.string().min(6) })
          .safeParse(credentials);
        if (!parsedCredentials.success) return null;

        const { email, password } = parsedCredentials.data;
        const response = await fetch(`${API_URL}/auth/login`, createFetchOptions({ email, password }));
        if (!response.ok) return null;

        const authResponse = await response.json();
        return extractUserFromToken(authResponse.accessToken, { refreshToken: authResponse.refreshToken });
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        return { ...token, ...user, backendExp: user.exp };
      }
      return token;
    },
    async session({ session, token }) {
      if (token) {
        session.user = { id: token.id, email: token.email, name: token.name };
        session.accessToken = token.accessToken;
        session.expires = new Date((token.backendExp || token.exp) * 1000).toISOString();
      }
      return session;
    },
  },
  session: { strategy: "jwt" },
});

export const { auth, signIn, signOut } = handler;
export { authConfig };

Notes:

  • Replace API_URL with your backend’s base URL.
  • Adjust extractUserFromToken to match your JWT payload structure (e.g., sub vs. userId).

2. Implement Token Expiry Logic in auth.config.ts

Purpose: Monitors token expiration and triggers a refresh when needed by redirecting to the refresh route.

Key Changes:

  • In the authorized callback, check if the token is within 5 minutes of expiring or if a manual refresh is requested (forceRefresh query param).
  • Set a cookie (auth-refresh-in-progress) to prevent multiple simultaneous refreshes.
  • Redirect to the refresh route with the original URL as a returnTo parameter.

Code:

// auth.config.ts
import type { NextAuthConfig } from "next-auth";
import { NextResponse } from "next/server";

const REFRESH_COOKIE_NAME = "auth-refresh-in-progress";

export const authConfig: NextAuthConfig = {
  callbacks: {
    async authorized({ auth, request }) {
      const nextUrl = request.nextUrl;

      if (auth?.user?.accessToken) {
        const backendExpSeconds = auth.user.backendExp as number;
        const tokenExpiryMs = backendExpSeconds * 1000;
        const currentTimeMs = Date.now();
        const fiveMinutesInMs = 5 * 60 * 1000;
        const forceRefresh = nextUrl.searchParams.has("forceRefresh");

        if (tokenExpiryMs <= currentTimeMs) {
          return NextResponse.redirect(new URL("/auth/logout", nextUrl));
        }

        if (!request.cookies.has(REFRESH_COOKIE_NAME) && (forceRefresh || tokenExpiryMs - currentTimeMs < fiveMinutesInMs)) {
          const searchParams = new URLSearchParams(nextUrl.search);
          searchParams.delete("forceRefresh");
          const returnUrl = nextUrl.pathname + (searchParams.toString() ? `?${searchParams.toString()}` : "");
          const refreshUrl = `/api/auth/refresh?returnTo=${encodeURIComponent(returnUrl)}`;

          const response = NextResponse.redirect(new URL(refreshUrl, nextUrl));
          response.cookies.set(REFRESH_COOKIE_NAME, "true", {
            maxAge: 30,
            path: "/",
            httpOnly: true,
            sameSite: "strict",
          });
          return response;
        }
      }

      // Allow access if authenticated, redirect to login if not
      return !!auth?.user || NextResponse.redirect(new URL("/auth/login", nextUrl));
    },
  },
  providers: [],
};

Notes:

  • Adjust the expiration threshold (e.g., fiveMinutesInMs) based on your needs.
  • Customize redirect paths (e.g., /auth/logout, /auth/login) to match your app’s routing.

3. Create the Refresh Route (app/api/auth/refresh/route.ts)

Purpose: Handles the token refresh by calling signIn with the current tokens and redirects the user back.

Key Changes:

  • Retrieve the current token using getToken.
  • Call signIn with the accessToken and refreshToken to update the session.
  • Clear the refresh-in-progress cookie and redirect to the original URL.

Code:

// app/api/auth/refresh/route.ts
import { NextRequest, NextResponse } from "next/server";
import { signIn } from "@/auth"; // Adjust path based on your project structure
import { getToken } from "next-auth/jwt";

const REFRESH_COOKIE_NAME = "auth-refresh-in-progress";

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const returnTo = searchParams.get("returnTo") || "/";

  const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET });
  if (!token?.refreshToken || !token?.accessToken) {
    const response = NextResponse.redirect(new URL("/auth/login", request.url));
    response.cookies.delete(REFRESH_COOKIE_NAME);
    return response;
  }

  try {
    await signIn("credentials", {
      accessToken: token.accessToken,
      refreshToken: token.refreshToken,
      redirect: false,
    });

    const response = NextResponse.redirect(new URL(returnTo, request.url));
    response.cookies.delete(REFRESH_COOKIE_NAME);
    return response;
  } catch (error) {
    console.error("Error refreshing tokens:", error);
    const response = NextResponse.redirect(new URL("/auth/login", request.url));
    response.cookies.delete(REFRESH_COOKIE_NAME);
    return response;
  }
}

Notes:

  • Ensure NEXTAUTH_SECRET is set in your environment variables.
  • Adjust the redirect fallback (/auth/login) as needed.

4. Configure Middleware (middleware.ts)

Purpose: Protects routes and applies the authentication logic from auth.config.ts.

Key Changes:

  • Use NextAuth’s auth function with the authConfig to handle route protection and token refresh triggers.

Code:

// middleware.ts
import NextAuth from "next-auth";
import { authConfig } from "./auth.config";

export default NextAuth(authConfig).auth;

export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|site.webmanifest|public|redirects/logout|api/auth|auth/.*|.*\\.png$|.*\\.ico$|.*\\.svg$|.*\\.jpg$|.*\\.jpeg$|.*\\.gif$).*)'],
};

Notes:

  • The matcher excludes API routes and static files; adjust it to include your protected routes (e.g., /dashboard/:path*).

Benefits

This workaround offers several advantages over other proposed solutions:

  • Security: The refresh token remains server-side, never exposed to the client, reducing the risk of token theft.
  • Simplicity: Leverages NextAuth’s signIn mechanism to update the session, avoiding manual token crafting or complex session management outside the ecosystem.
  • Automatic Refresh: Tokens are refreshed proactively (e.g., within x minutes/hours of expiration), preventing authentication disruptions and improving user experience.
  • Manual Refresh Support: Allows triggering a refresh via a forceRefresh query parameter, useful for testing or edge cases.
  • Maintainability: Keeps logic centralized within NextAuth’s configuration, making it easier to adapt to future official fixes.

Conclusion

This workaround effectively resolves the NextAuth v5 JWT refresh token persistence bug by re-signing in the user with updated tokens, ensuring the session reflects the latest authentication state. By implementing the steps above, developers can maintain a secure, seamless authentication flow in their Next.js applications. Test this solution in your environment, adjusting API endpoints and JWT parsing to fit your backend. Monitor NextAuth’s GitHub repository for official updates, and consider contributing feedback to refine this approach for the community. With this documentation, you’re equipped to tackle the bug head-on while keeping your application robust and user-friendly.

@krizh-p
Copy link

krizh-p commented Mar 12, 2025

This is February 2025, and this issue still hasn't been resolved. Even the suggested middleware workarounds aren't working. Any updates would be greatly appreciated!

cc @balazsorban44 @ThangHuuVu @ndom91

Middleware workarounds definitely still work and are easy to implement...granted there's no reason for this to still be an active issue and the docs still misleadingly say token refreshes work in the callbacks.

@eulucasmoraes
Copy link

@david-lagrange, How are you?

Dude, could you make the project available in some repository?

I'm asking this because I tried to implement it here and the refresh only worked the first two times the realod occurs, from then on it doesn't work anymore.

I was able to solve this by sending the user to another page and then returning to the original page after the tokens are updated, but this isn't providing a good experience for the application.

@fede-s
Copy link

fede-s commented Apr 4, 2025

Hi, so I just noticed on v5 docs https://authjs.dev/getting-started/installation that

Step 3, item 3 says

Add optional Middleware to keep the session alive, this will update the session expiry every time its called.

./middleware.ts

export { auth as middleware } from "@/auth"

I don't know why nobody is saying it or I didn't came across it, but it turns out that if you add that to your middleware, you can have the refresh token funcitonality in jwt and it will kind of work as its supposed to.... It will remember the new token on the following calls that is.
But there is still the issue that jwt will be called few more times before the first call returns, so if your refresh token is for 1 time use, you still have to solve that somehow.

I'm working with "next": "15.2.3" using app router and "next-auth": "5.0.0-beta.25"

@radokristof
Copy link

@fede-s

But there is still the issue that jwt will be called few more times before the first call returns, so if your refresh token is for 1 time use, you still have to solve that somehow.

For me that was the only issue with next-auth as well. Also this is stated in authjs docs as well:
https://authjs.dev/guides/refresh-token-rotation

There is an inherent limitation of the following guides that comes from the fact, that - for security reasons - refresh_tokens are usually only usable once. Meaning that after a successful refresh, the refresh_token will be invalidated and cannot be used again. Therefore, in some cases, a race-condition might occur if multiple requests will try to refresh the token at the same time. The Auth.js team is aware of this and would like to provide a solution in the future. This might include some “lock” mechanism to prevent multiple requests from trying to refresh the token at the same time, but that comes with the drawback of potentially creating a bottleneck in the application. Another possible solution is background token refresh, to prevent the token from expiring during an authenticated request.

And it looks like this can only be solved by refreshing the token in the middleware.

@0-don
Copy link

0-don commented Apr 23, 2025

here is my attempt for refresh tokens using "next-auth": "^5.0.0-beta.26" without a middleware

auth.ts

import { jwtDecode } from "jwt-decode";
import NextAuth from "next-auth";
import Keycloak from "next-auth/providers/keycloak";

const keycloakIssuer = process.env.AUTH_KEYCLOAK_ISSUER;
const keycloakId = process.env.AUTH_KEYCLOAK_ID;
const keycloakSecret = process.env.AUTH_KEYCLOAK_SECRET;

const refreshTokens = async (refreshToken: string) => {
  const res = await fetch(`${keycloakIssuer}/protocol/openid-connect/token`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      client_id: keycloakId,
      client_secret: keycloakSecret,
      grant_type: "refresh_token",
      refresh_token: refreshToken,
    }),
  });
  return res.json();
};

export const { auth, handlers, signIn, signOut } = NextAuth({
  providers: [Keycloak],
  callbacks: {
    async jwt({ token, account, user }) {
      if (account?.access_token && user) {
        const decoded = jwtDecode<any>(account.access_token);

        return {
          ...token,
          accessToken: account.access_token,
          refreshToken: account.refresh_token!,
          keycloakExp: decoded.exp,
        };
      }

      const currentTimestamp = Math.floor(Date.now() / 1000);
      const secondsLeft = token.keycloakExp - currentTimestamp;

      console.log(
        `Time until token expiry: ${secondsLeft}s (exp: ${token.keycloakExp}, now: ${currentTimestamp})`,
      );

      if (secondsLeft > 60) return token;

      try {
        const tokens = await refreshTokens(token.refreshToken);
        if (tokens.error) return { ...token, error: "RefreshTokenError" };
        const decoded = jwtDecode<any>(tokens.access_token);
        return {
          ...token,
          accessToken: tokens.access_token,
          refreshToken: tokens.refresh_token || token.refreshToken,
          keycloakExp: decoded.exp,
        };
      } catch {
        return token;
      }
    },

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

next-auth.d.ts

import { type DefaultSession } from "next-auth";
import "next-auth/jwt";

type AuthError = "RefreshTokenError" | "AccessTokenError";

declare module "next-auth" {
  interface Session {
    error?: AuthError;
    accessToken: string;
    refreshToken: string;
    user: {} & DefaultSession["user"];
  }
}

declare module "next-auth/jwt" {
  interface JWT {
    error?: AuthError;
    accessToken: string;
    refreshToken: string;
    keycloakExp: number;
  }
}

auth-provider.tsx below session-provider, fixes logout problem on expired token

"use client";
import { signOut, useSession } from "next-auth/react";
import { ReactNode } from "react";

export function AuthProvider(props: {children: ReactNode}) {
  const { data: session } = useSession();

  useEffect(() => {
    if (session?.error !== "RefreshTokenError") return;
    signOut();
  }, [session?.error]);

  return <>{props.children}</>;
}

@voyager5874
Copy link

voyager5874 commented Apr 27, 2025

here is my attempt for refresh tokens using "next-auth": "^5.0.0-beta.26" without a middleware

Is this

But there is still the issue that jwt will be called few more times before the first call returns, so if your refresh token is for 1 time use, you still have to solve that somehow.

not an issue anymore ?

Refresh logic in jwt or authorized will not refresh tokens if a user stays on the same page but there are still some requests to an external api which will return 401 when token expires. Will it?
I had to add refresh logic to my api layer. Now I'm using a server function ('use server') with await auth() inside it. When called from the client (on 401 code) it creates POST request to the current url, this fires authorized callback where I have refresh logic (redirect to next.js api route). But this is POST request, so I added one more route handler.

and by the way

export { auth as middleware } from "@/auth"

this is for next-auth callbacks to be a part of the middleware I believe

@0-don
Copy link

0-don commented Apr 27, 2025

not an issue anymore ?

Usually, if the keycloack cookie expires, you need to log out because you need a cookie for the new session, so the requests not working is fine, which jwt-callback & auth-provider.tsx will catch and redirect you to sign in

@voyager5874
Copy link

https://authjs.dev/guides/refresh-token-rotation:

There is an inherent limitation of the following guides that comes from the fact, that - for security reasons - refresh_tokens are usually only usable once. Meaning that after a successful refresh, the refresh_token will be invalidated and cannot be used again. Therefore, in some cases, a race-condition might occur if multiple requests will try to refresh the token at the same time. The Auth.js team is aware of this and would like to provide a solution in the future. This might include some “lock” mechanism to prevent multiple requests from trying to refresh the token at the same time, but that comes with the drawback of potentially creating a bottleneck in the application. Another possible solution is background token refresh, to prevent the token from expiring during an authenticated request.

https://github.com/DirtyHairy/async-mutex#readme

@fahim-sepas
Copy link

https://github.com/DirtyHairy/async-mutex#readme

@voyager5874 the mutex one doesn't seem to work either or my implementation is not correct. can you share a working implementation of using async-mutex?

@voyager5874
Copy link

voyager5874 commented May 5, 2025

I didn't try mutex inside auth.js callbacks actually - I'm just using it inside api layer very similar to RTK query docs https://redux-toolkit.js.org/rtk-query/usage/customizing-queries#preventing-multiple-unauthorized-errors.
I gone with the credentials signIn and route handler workaround. My app uses external backend for the data.

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

No branches or pull requests