DEV Community

Mateusz Baranowski
Mateusz Baranowski

Posted on • Updated on

Next.js Authentication - JWT Refresh Token Rotation with NextAuth.js

Recently I was implementing authentication in a Next.js app. After weighing in a few options, I’ve settled on NextAuth.js, as it's tailor-made for Next.js, with support for a wide range of providers.

The authentication flow, while using only an access token was pretty straightforward to implement. The problems arose when I added a refresh token and was trying to silently authenticate users.

At the moment of writing, there is no official best practice for how to implement token rotation in NextAuth.js. In the future, there might be a built-in solution for JWT rotation, so it’s always a good idea to check the docs first. Here is a tutorial, that might be sufficient for your use case.

This brief tutorial is my take on this issue. I hope someone will find it useful. I’m using Next.js 12.1.0, NextAuth.js 4.2.1, and a credentials provider with a separate backend that issues tokens. However, presented concepts should apply to other providers.

The Authentication Flow

When a user enters his credentials, the backend verifies them and returns the accessToken, accessTokenExpiry, and refreshToken.

  • The accessToken should have a relatively short life span, let’s say 24 hours.
  • The refreshToken on the other hand should be long-lived, with an expiry time of let’s say 30 days. It will be used to obtain new accessTokens.
  • The accessTokenExpiry is a timestamp of when the token becomes invalid. It can also be embedded into the accessToken itself, and later decoded to obtain the expiry timestamp.

We can set the refetchInterval, to periodically ask backend for a new token. The call should happen before the accessToken expires so that the user stays authenticated. If the call happens after the accessToken has expired, we still have a chance to refresh it, as long as refreshToken is still valid. However, if the call happens after the refreshToken has expired, we should sign out the user.

The Server Side

We create the file at pages/api/auth/[…nextauth].js in which we’ll place the backend logic of the token rotation. There is a CredentialsProvider, where we implement the authentication with credentials. The object returned, will be passed to the jwt callback.

jwt callback is where we decide whether the token is ready to be refreshed. session callback is where we specify what will be available on the client with useSession() or getSession().

When it comes to the refresh time, I’ve decided to give the token a window it can be refreshed, before it expires. If we would to just do token.accessTokenExpiry - Date.now() > 0, the refetchInterval will either call session refresh too soon, resulting in no token refresh for the next interval or too late, leaving the user without a valid authentication token for some time.

[…nextauth].js

import axios from 'axios';
import NextAuth from 'next-auth';
import CredentialsProvider from "next-auth/providers/credentials";

async function refreshAccessToken(tokenObject) {
    try {
        // Get a new set of tokens with a refreshToken
        const tokenResponse = await axios.post(YOUR_API_URL + 'auth/refreshToken', {
            token: tokenObject.refreshToken
        });

        return {
            ...tokenObject,
            accessToken: tokenResponse.data.accessToken,
            accessTokenExpiry: tokenResponse.data.accessTokenExpiry,
            refreshToken: tokenResponse.data.refreshToken
        }
    } catch (error) {
        return {
            ...tokenObject,
            error: "RefreshAccessTokenError",
        }
    }
}

const providers = [
    CredentialsProvider({
        name: 'Credentials',
        authorize: async (credentials) => {
            try {
                // Authenticate user with credentials
                const user = await axios.post(YOUR_API_URL + 'auth/login', {
                    password: credentials.password,
                    email: credentials.email
                });

                if (user.data.accessToken) {
                    return user.data;
                }

                return null;
            } catch (e) {
                throw new Error(e);
            }
        }
    })
]

const callbacks = {
    jwt: async ({ token, user }) => {
        if (user) {
            // This will only be executed at login. Each next invocation will skip this part.
            token.accessToken = user.data.accessToken;
            token.accessTokenExpiry = user.data.accessTokenExpiry;
            token.refreshToken = user.data.refreshToken;
        }

        // If accessTokenExpiry is 24 hours, we have to refresh token before 24 hours pass.
        const shouldRefreshTime = Math.round((token.accessTokenExpiry - 60 * 60 * 1000) - Date.now());

        // If the token is still valid, just return it.
        if (shouldRefreshTime > 0) {
            return Promise.resolve(token);
        }

        // If the call arrives after 23 hours have passed, we allow to refresh the token.
        token = refreshAccessToken(token);
        return Promise.resolve(token);
    },
    session: async ({ session, token }) => {
        // Here we pass accessToken to the client to be used in authentication with your API
        session.accessToken = token.accessToken;
        session.accessTokenExpiry = token.accessTokenExpiry;
        session.error = token.error;

        return Promise.resolve(session);
    },
}

export const options = {
    providers,
    callbacks,
    pages: {},
    secret: 'your_secret'
}

const Auth = (req, res) => NextAuth(req, res, options)
export default Auth;
Enter fullscreen mode Exit fullscreen mode

The Client Side

In _app.js we wrap our app with <SessionProvider>. We then set the refetchInterval to the specific value in seconds. The issue here is that if you set a constant value, every time the user refreshes the page, the counter restarts. So if we set a refetchInterval to 23 hours 30 minutes, the user leaves the page, and comes back after 12 hours, the counter starts again. As a result, between Date.now() + 12 hours and Date.now() + 23 hours 30 minutes, we’ve got invalid token.

_app.js

import { SessionProvider } from 'next-auth/react';
import { useState } from 'react';
import RefreshTokenHandler from '../components/refreshTokenHandler';

function MyApp({ Component, pageProps }) {
    const [interval, setInterval] = useState(0);

    return (
        <SessionProvider session={pageProps.session} refetchInterval={interval}>
            <Component {...pageProps} />
            <RefreshTokenHandler setInterval={setInterval} />
        </SessionProvider>
    )
}

export default MyApp;
Enter fullscreen mode Exit fullscreen mode

To combat this, I’ve made a RefreshTokenHandler component, which has to be placed inside the <SessionProvider> so that we have access to the useSession hook, from which we can get the access token expiry time. Then, we calculate the remaining time till the expiration, minus a 30-minute margin. Now every time user refreshes the page, the interval will be set to a correct time remaining.

refreshTokenHandler.js

import { useSession } from "next-auth/react";
import { useEffect } from "react";

const RefreshTokenHandler = (props) => {
    const { data: session } = useSession();

    useEffect(() => {
        if(!!session) {
            // We did set the token to be ready to refresh after 23 hours, here we set interval of 23 hours 30 minutes.
            const timeRemaining = Math.round((((session.accessTokenExpiry - 30 * 60 * 1000) - Date.now()) / 1000));
            props.setInterval(timeRemaining > 0 ? timeRemaining : 0);
        }
    }, [session]);

    return null;
}

export default RefreshTokenHandler;
Enter fullscreen mode Exit fullscreen mode

The token refresh should now work properly. We can create a hook, that will sign out the user if the refresh token expires. If needed, we can make redirects, and hold state whether the user is authenticated.

useAuth.js

import { signOut, useSession } from "next-auth/react";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";

export default function useAuth(shouldRedirect) {
    const { data: session } = useSession();
    const router = useRouter();
    const [isAuthenticated, setIsAuthenticated] = useState(false);

    useEffect(() => {
        if (session?.error === "RefreshAccessTokenError") {
            signOut({ callbackUrl: '/login', redirect: shouldRedirect });
        }

        if (session === null) {
            if (router.route !== '/login') {
                router.replace('/login');
            }
            setIsAuthenticated(false);
        } else if (session !== undefined) {
            if (router.route === '/login') {
                router.replace('/');
            }
            setIsAuthenticated(true);
        }
    }, [session]);

    return isAuthenticated;
}
Enter fullscreen mode Exit fullscreen mode

We can use this hook in our pages, to display a message if the user is unauthenticated, or let the app redirect the user to the login page.

const isAuthenticated = useAuth(true);

That’s pretty much it. We can now test this mechanism with signIn() and signOut() methods in index.js. When it comes to the time margins we did set in this example, they are pretty generous. It’s because I found, that in some edge cases the refetchInterval can slightly lag.

index.js

import { signIn, signOut } from 'next-auth/react';

export default function Home() {
  return (
    <>
      <button onClick={() => signIn('credentials', { email: 'example@example.com', password: 'example' })}>
        Sign in
      </button>
      <button onClick={() => signOut()}>
        Sign out
      </button>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Wrap Up

We went through one way we can implement the JWT token rotation with NextAuth.js. Thanks for following along, and if you’ve got a better solution to JWT token rotation in NextAuth.js, please feel free to post a comment below, or hit me up on Twitter.

Discussion (21)

Collapse
htissink profile image
Henrick Tissink

Absolutely excellent article Mateusz! :D what a lifesaver! ...just a small heads up :) not sure if intentional - the hook you create is called useAuth but after that, you mention const isAuthenticated = useWithAuth(true) - maybe a small typo?

Collapse
mabaranowski profile image
Mateusz Baranowski Author

Yeh, a typo, it should be useAuth. Fixed it, thank you Henrick! :)

Collapse
htissink profile image
Henrick Tissink

useAuth(true) - could you explain how you pass in the boolean param?

Thread Thread
mabaranowski profile image
Mateusz Baranowski Author • Edited on

I originally used it to decide if I want to be redirected or not:

export default function useAuth(shouldRedirect) {
  ...
  signOut({ callbackUrl: '/login', redirect: shouldRedirect });
  ...
}
Enter fullscreen mode Exit fullscreen mode

When the user is on the unprotected page, and for whatever reason his token expires, I want to silently log him out, without redirecting him to the login page.

I'll update the example code with this redirect flag :)

Thread Thread
htissink profile image
Henrick Tissink

That functionality sounds great :) thanks for updating!

Collapse
sleepwalky profile image
Alexander Kleshchukevich

Thank you for writing this article! It's so much better than the example in next-auth's docs. Lifesaver, indeed!

Collapse
tasmiarahmantanjin profile image
Tasmia Rahman

Hi, Thanks for your tutorial this is really good and explains a lot. Currently i am trying add refreshToken to my company project, In my case we want to set i a bit differently. For example the requirement is I need to send accessToken and refreshToken from backend Normally with res.body then Frontend need to set it in Header and then I need to get the refreshToken in auth/refresh-token endpoint with req.headers. I am a bit confused how to achieve this. Do you think it's possible? Any help will be highly appreciated. thanks a lot in advance!

Collapse
mabaranowski profile image
Mateusz Baranowski Author

On the server, I use express-jwt package, which takes care of reading the authorization header. On the client, you can set the auth header, with accessToken taken from useSession:

axios.get(API_URL + 'endpoint', {
                headers: {
                    'Authorization': `Bearer ${session.accessToken}`
                }
            })
Enter fullscreen mode Exit fullscreen mode

Do You want to refresh the token from the client code?

Collapse
tasmiarahmantanjin profile image
Tasmia Rahman

Thanks a lot Mateusz for the reply! I was able to achieve what i wanted to, front nextauth.js I called the refresh-token end point. Now, I am trying logOut on refreshToken error, and refreshTokeExpire. Because of the initial project setup I am still trying out the best way to logOut user on refreshTokenExpire. I noticed you used useAuth hook, I am struggling to use in correct place on my code! As this is my very first task on nextauth therefor it´s seems a bit hard to me!

Thread Thread
mabaranowski profile image
Mateusz Baranowski Author

You can call the useAuth hook directly from the page (pages folder).

export default function Page() {
    const isAuthenticated = useAuth(true);

    return (
        <>
            {isAuthenticated ?
                <YourComponent />
                : null}
        </>
    )
}
Enter fullscreen mode Exit fullscreen mode

In the example above, we use isAuthenticated to decide if we should render the page. If you do not need this functionality, calling useAuth(true) should be sufficient. This hook will log out the user when his token expires while being on that page.

Thread Thread
tasmiarahmantanjin profile image
Tasmia Rahman

Thanks Mateusz! I got it, but my boss wants me to call auto logout inside nextauth.js.
`
events: {
session: async ({ session }) => {
// if RefreshAccessTokenError then logout
if (session?.error === 'RefreshAccessTokenError') {
signOut()
}

  // if refreshTokenExpiresIn then signOut
  if (
    session?.refreshTokenExpiresIn &&
    Date.now() > new Date(session.refreshTokenExpiresIn).getTime()
  ) {
    console.log('I am logging out')
    signOut()
  }
}
Enter fullscreen mode Exit fullscreen mode

}
`

I kanda figure one way out which is using session event like below. He don't want to call hooks on pages. However with event I am getting a error message also like error - unhandledRejection: ReferenceError: window is not defined .That's why it's a bit complicated in my case!

Thread Thread
mabaranowski profile image
Mateusz Baranowski Author

You are getting "window is not defined" because you are trying to call a signOut function (which requires a browser window) in a session callback inside [...nextauth].js.

[...nextauth].js lives in pages/api/auth, and pages/api in Next.js are the server functions. You can look up the documentation on signOut.

If you want to logout a user from the session callback, you should probably use POST /api/auth/signout. Call it as you would a regular endpoint. This is used by signOut() internally.

I'm not sure if it's gonna work, but it's worth exploring. Let me know how you did :)

Collapse
marcelx8 profile image
Marcelx8

Thanks for sharing your experience and guidance on Next-Auth. It's my first time implementing authentication myself and with so many authentication libs out there, this seemed to be the one that has so much given functionality.

After my 3 day search and testing, I just couldn't get enough documentation or examples explaining the jwt and session callbacks and its uses together, especially with the CredentialsProvider.

You have provided reusable pieces of code as well as explaining them. Appreciated sincerely.

Collapse
jon_rivera_9152520f0c7293 profile image
Jon Rivera • Edited on

hey Mateusz!

thanks so much for this thorough tutorial. Currently, my auth flow returns me a code on the url, that I can POST and get a accessToken, accessTokenExpiry and refreshToken. Using the CredentialsProvider, I get that my credentials are wrong.

Is is required to provide credentials (username and password) when using CredentialsProvider ?

Collapse
juancamiloqhz profile image
Juan Camilo QHz

Really nice explanation thx

Collapse
developerbishwas profile image
Bishwas Bhandari

Thanks a lot for sharing, useful. I do have some confusions on it, bbut I guess I'll figure it out.

Collapse
napster profile image
Marouane Etaraz

Thanks lot brother ! that what i'm looking for. 😍😍

Collapse
rodriguesvinicius profile image
Vinicius Alves

accessTokenExpiry is in milliseconds ?

Collapse
shobanamg profile image
shobanamg • Edited on

Hi Mateusz, Great article. I am using auth0 as provider. The way the interval is handled, doesn't it keep extending the NextAuth session life time. When will the session get invalidated

Collapse
aapdomingues profile image
aapdomingues • Edited on

Hello Mateusz! Thank you very much for sharing!!

Your article helps me a lot!!

Collapse
tonymarques profile image
Tony Marques

Hello do you have a auth/login file please ?