next-auth: Tokens rotation does not persist the new token

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.

About this issue

  • Original URL
  • State: open
  • Created a year ago
  • Reactions: 30
  • Comments: 48 (3 by maintainers)

Most upvoted comments

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

As of authjs@5.0 (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.

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?

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 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.

@rohanrajpal @diegogava @eirik-k @karlbessette

A solution is to use middleware.ts, as it’s inline with the official next.js docs: https://nextjs.org/docs/pages/building-your-application/authentication

Solution available here (hats off to the guy Rinvii who first came up with it): https://github.com/nextauthjs/next-auth/discussions/9715

In short, you implement the refresh token rotation logic in middleware, and force getServerSession() to read the new session and send it back to the browser by setting the cookies. You do something like this:

import { NextResponse, type NextMiddleware, type NextRequest } from "next/server";
import { encode, getToken, type JWT } from "next-auth/jwt";

import {
	admins,
	SESSION_COOKIE,
	SESSION_SECURE,
	SESSION_TIMEOUT,
	SIGNIN_SUB_URL,
	TOKEN_REFRESH_BUFFER_SECONDS
} from "./config/data/internalData";

let isRefreshing = false;

export function shouldUpdateToken(token: JWT): boolean {
	const timeInSeconds = Math.floor(Date.now() / 1000);
	return timeInSeconds >= token?.expires_at - TOKEN_REFRESH_BUFFER_SECONDS;
}

export async function refreshAccessToken(token: JWT): Promise<JWT> {
	if (isRefreshing) {
		return token;
	}

	const timeInSeconds = Math.floor(Date.now() / 1000);
	isRefreshing = true;

	try {
		const response = await fetch(process.env.AUTH_ENDPOINT + "/o/token/", {
			headers: { "Content-Type": "application/x-www-form-urlencoded" },
			body: new URLSearchParams({
				client_id: process.env.CLIENT_ID,
				client_secret: process.env.CLIENT_SECRET,
				grant_type: "refresh_token",
				refresh_token: token?.refresh_token
			}),

			credentials: "include",
			method: "POST"
		});

		const newTokens = await response.json();

		if (!response.ok) {
			throw new Error(`Token refresh failed with status: ${response.status}`);
		}

		return {
			...token,
			access_token: newTokens?.access_token ?? token?.access_token,
			expires_at: newTokens?.expires_in + timeInSeconds,
			refresh_token: newTokens?.refresh_token ?? token?.refresh_token
		};
	} catch (e) {
		console.error(e);
	} finally {
		isRefreshing = false;
	}

	return token;
}

export function updateCookie(
	sessionToken: string | null,
	request: NextRequest,
	response: NextResponse
): NextResponse<unknown> {
	/*
	 * BASIC IDEA:
	 *
	 * 1. Set request cookies for the incoming getServerSession to read new session
	 * 2. Updated request cookie can only be passed to server if it's passed down here after setting its updates
	 * 3. Set response cookies to send back to browser
	 */

	if (sessionToken) {
		// Set the session token in the request and response cookies for a valid session
		request.cookies.set(SESSION_COOKIE, sessionToken);
		response = NextResponse.next({
			request: {
				headers: request.headers
			}
		});
		response.cookies.set(SESSION_COOKIE, sessionToken, {
			httpOnly: true,
			maxAge: SESSION_TIMEOUT,
			secure: SESSION_SECURE,
			sameSite: "lax"
		});
	} else {
		request.cookies.delete(SESSION_COOKIE);
		return NextResponse.redirect(new URL(SIGNIN_SUB_URL, request.url));
	}

	return response;
}

export const middleware: NextMiddleware = async (request: NextRequest) => {
	const token = await getToken({ req: request });
	const isAdminPage = request.nextUrl.pathname.startsWith("/epa");
	const isAuthenticated = !!token;

	let response = NextResponse.next();

	if (!token) {
		return NextResponse.redirect(new URL(SIGNIN_SUB_URL, request.url));
	}

	if (shouldUpdateToken(token)) {
		try {
			const newSessionToken = await encode({
				secret: process.env.NEXTAUTH_SECRET,
				token: await refreshAccessToken(token),
				maxAge: SESSION_TIMEOUT
			});
			response = updateCookie(newSessionToken, request, response);
		} catch (error) {
			console.log("Error refreshing token: ", error);
			return updateCookie(null, request, response);
		}
	}

	if (isAdminPage && isAuthenticated && !admins.includes(token.email!)) {
		return NextResponse.redirect(new URL("/forbidden", request.url));
	}

	return response;
};

export const config = {
	matcher: ["/dashboard/:path*", "/epa/:path*"]
};

Make sure you read all the comments on the references page to apply this to your situation

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.

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: https://github.com/nextauthjs/next-auth/discussions/6642#discussioncomment-5942013

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).

I found out that the session wasn’t updating after an api request. After digging a lot, I’ve found this:

import { useSession } from 'next-auth/react';
const { update } = useSession();
update(); // use this after your API call

Call update() in your client after making the API call and it’ll sync the backend session with your cookies and you’ll now have the new AccessToken and RefreshToken.

Any updates on this issue?

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
      }
  }
}

@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: https://github.com/nextauthjs/next-auth/discussions/9715#discussioncomment-8319836 and here: https://github.com/nextauthjs/next-auth/discussions/9715#discussioncomment-8476838).

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: https://github.com/nextauthjs/next-auth/issues/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.

This answers it? https://authjs.dev/guides/basics/callbacks#session-callback

image

I am thinking of using some kind of persistent storage option to store tokens and other metadata (refresh token, expiry time). Then again the problem starts with latency to get token data from storage? Any other probable solutions?

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

Have a look at the issue here: https://github.com/nextauthjs/next-auth/discussions/4229. Managed to get it work using update() from the useSession hook.

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?

@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.

@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.

@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 - https://github.com/mrbodich/next-auth-example-fork/pull/1 for detailed code.

@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
      }
  }
}