Next.js Authentication - JWT Refresh Token Rotation with NextAuth.js
Tags: webdev, nextjs, authentication, jwt
Published: 2022-03-04 18:16:27

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](https://next-auth.js.org/tutorials/refresh-token-rotation) 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`
```jsx
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;
```

## 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`
```jsx
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;
```

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`
```jsx
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;
```

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`
```jsx
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;
}
```

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`
```jsx
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>
    </>
  )
}
```

## 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](https://twitter.com/matebaranowski).