Handling Refresh Token with Next.js, Auth.js (next-auth v5) Credentials Provider, and HTTPOnly Cookie

Using Server Actions and Axios Interceptors

·

7 min read

Handling Refresh Token with Next.js, Auth.js (next-auth v5) Credentials Provider, and HTTPOnly Cookie

I worked on a Next.js app that connects to a NestJS backend for authentication using an access token and a refresh token stored as HttpOnly Cookie. The main challenge is that cookies are read-only in server components; they can only be used in server actions and route handlers.

Access tokens, which are required for all requests to access the backend, are short lived. When an access token expires, a new one needs to be obtained using the long-lived refresh token. As long as the refresh token is valid and matches the ones stored in the backend, the authentication server will issue a new access token and the refresh token will also be renewed. In our application, access tokens last for 30minutes, while refresh tokens expire in 7 days. This means if the user has not logged in or made any requests, they will need to re-authenticate.

In this article, I’ll cover two different strategies to automatically refresh access token: using a request wrapper and using axios request interceptor. The article assumes a basic understanding of Next.js and next-auth. You can find the upgrade guide from next-auth v4 to v5 here.

Only key parts of the code will be shown but the full code is available here:

https://github.com/cherylli/next-refresh-token-demo

Setup Auth.js (next-auth v5)

Install Auth.js following this page.

Credentials Provider

Set up the credentials provider based on this page. In the authorize function, send a request to the backend to log in:

const authResponse = await axios.post(
   `${process.env.API_BASEURL}/auth/login`, {
       "email": credentials.email,
       "password": credentials.password
})

Then, retrive the cookies from the response headers and set cookies in the browser using next.js cookies. Since this is a server component, it doesn’t directly set cookies in the browser.

const authCookies = authResponse.headers['set-cookie']
await setCookies(authCookies)

Parse the cookies and set them:

// set-cookies.ts
import {parse} from "cookie";
import {cookies} from "next/headers";

export const setCookies = async (authCookies: string[] | undefined) => {
    'use server'
    if (authCookies && authCookies.length > 0) {
        authCookies.forEach(cookie => {
            const parsedCookie = parse(cookie)
            const [cookieName, cookieValue] = Object.entries(parsedCookie)[0]

            cookies().set({
                name: cookieName,
                value: cookieValue,
                httpOnly: true,
                maxAge: parseInt(parsedCookie["Max-Age"]),
                path: parsedCookie.path,
                sameSite: 'none',
                expires: new Date(parsedCookie.expires),
                secure: true,
            })
        })
    }
}

Back in auth.ts, we send another request to the server to retrieve user information. This step is optional, but this is commonly done to display user information. The important part here is to attach the cookies in the request headers AND include withCredentials. If the request is sent from a client component, we can omit setting cookie in headers as they are automatically attached.

const userRes = await axios.get(
                `${process.env.API_BASEURL}/users/me`,
   {
     headers: {
       cookie: authCookies
     },
     withCredentials: true
    },
)

Return user details, which logs the user in,

return {
    id: userRes.data.id,
    name: `${userRes.data.firstName} ${userRes.data.lastName}`,
    email: userRes.data.email,
    roles: userRes.data.roles,
}

If we want to use role-based access, we can use the signIn callback, where ExtendedUser type is a custom type which defines our own user type using module augmentation.

const callbacks = {
    signIn({user}: { user: ExtendedUser }) {
        const allowedRoles = ['admin', 'evaluator']
        return !!user.roles?.some(role => allowedRoles.includes(role))
    }
}

export the authConfig,

const authOptions: NextAuthConfig = {
    session: {
        strategy: 'jwt'
    },
    providers: [credentialsConfig],
    callbacks,
    secret: process.env.AUTH_SECRET,
} satisfies NextAuthConfig


export const {handlers, auth, signIn, signOut} = NextAuth(authOptions)

Requesting a new access token using the /refresh route

While this is not required for the solution, it helps with getting to the final implementation. Sending a request to the backend from client and server components are slightly different.

Client Components

First, we create some server actions for next-auth signIn and signOut.

// auth-helper.ts
'use server'

import {signIn, signOut} from "@/auth";

export const signInSA = async (redirect: string = '/') => {
    await signIn('credential', {redirectTo: redirect})
}

export const signOutSA = async () => {
    await signOut()
}

When requesting in a client component, it is not necessary to manually attach the cookies (access token and refresh token), as the browser automatically includes the stored cookies. On success, the browser cookie is updated with the new tokens. If the refresh token is missing or invalid, a 401 error is returned. In this case, the user will be logged out and redirected to the current page after signIn.

// app/page.tsx (client component)
const handleRefreshClient = async () => {
    await axios.post(`${process.env.NEXT_PUBLIC_API_BASEURL}/auth/refresh`, {}, {
        headers: {
            'Content-Type': 'application/json',
        },
        withCredentials: true
    }).catch(e => {
        if (axios.isAxiosError(e)) {
            if(e.response?.status ===401) {
                // refresh token expired
                signInSA(pathname)
             }
         }
         throw e
    })
}

Server Components

When refreshing tokens in a server component, we will need to manually set cookies, which requires using a server action or a route handler.

It is important to call the refresh server action from a client component. When calling from a server component, I get the follow error.

Error: Cookies can only be modified in a Server Action or Route Handler. Read more here.

Here is the code for handling the refresh server side. In the server component, we need to get the refresh token from the browser cookies, attach it to the request, then update the browser cookie with the new tokens.

// page.tsx (client component)
const handleRefreshServer = async () => {
    await refreshServer()
}
// refresh.ts (server component)
'use server'
import axios from "axios";
import {cookies} from "next/headers";
import {setCookies} from "@/utils/set-cookies";
import {signInSA} from "@/utils/auth-helper";

export const refreshServer = async () => {
    const refreshToken = cookies().get('refresh_token')?.value || ''

    try {
        const refreshResponse = await axios.post(`${process.env.API_BASEURL}/auth/refresh`, {}, {
            headers: {
                'Content-Type': 'application/json',
                Cookie: `refresh_token=${refreshToken}`
            },
            withCredentials: true
        })
        await setCookies(refreshResponse.headers['set-cookie'])
    } catch (e) {
        if (axios.isAxiosError(e)) {
            if(e.response?.status ===401) {
                // refresh token expired
                await signInSA()
            }
        }
        throw e
    }
}

Automatically Request a new Access Token

We can check if an access token is present in the cookies, if it is, proceed with the request. Otherwise, we request a new access token using the refresh token. If the refresh token is missing, the user will be redirected to the sign-in page to get new tokens upon sign in. This article covers two strategies:

  1. Using a request wrapper

  2. Using axios interceptor

Request Wrapper

This method works with both Axios and Fetch. It essentially wraps the request in a function and checks if the access token is present before sending the actual request. If the access token is missing, it fetches the tokens and attaches the new access token to the request.

  1. Setup the wrapper function (server action)
// services/requests.ts
'use server'

import {cookies} from "next/headers";
import {refreshServer} from "@/services/refresh";
import axios from "axios";

export const getRequest = async (path: string) => {
    let accessToken = cookies().get('access_token')?.value || ''

    if (!accessToken) {
        await refreshServer()
    }
    accessToken = cookies().get('access_token')?.value || ''

    const res = await axios.get(`${process.env.API_BASEURL}/${path}`, {
        headers: {
            Cookie: `access_token=${accessToken}`
        },
        withCredentials: true
    })
    return res.data
}
  1. Using the wrapper ( event handler on page.tsx)
const getMeServer = async () => {
    const me = await getRequest('users/me')
    console.log(me)
}

Axios Request Interceptor

First, we create a reusable Axios instance. Then, we set up a request interceptor that runs every time we use this Axios instance before sending a request. In the interceptor, we check whether an access token is present. If the access token is missing, we request a new one and attach it to the request.

  1. Set up the interceptor
// utils/axios.ts
'use server'
import axios from "axios";
import {cookies} from "next/headers";
import {refreshServer} from "@/services/refresh";

// baseURL handles both baseURLs in both frontend and backend (both docker and non docker)
const axiosInstance = axios.create({
    baseURL: process.env.API_BASEURL || process.env.NEXT_PUBLIC_API_BASEURL,
    withCredentials: true,
    headers: {
        'Content-Type': 'application/json',
    }
})

axiosInstance.interceptors.request.use( async (config)=> {
    const accessToken = cookies().get('access_token')?.value || ''
    if(!accessToken) {
        await refreshServer()
        const newAccessToken = cookies().get('access_token')?.value || ''
        config.headers.Cookie = `access_token=${newAccessToken}`
    } else {
        config.headers.Cookie = `access_token=${accessToken}`
    }
    return config
}, (error)=> {
    return Promise.reject(error)
} )

export default axiosInstance
  1. In a server component, create a function to use the axios instance
// services/refresh.ts
'use server'
export const getRequestWithInterceptor = async (path: string) => {
    const res = await axiosInstance.get(path)
    return res.data
}
  1. Using the interceptor (event handler on page.tsx)
// pages.tsx
const getMeInterceptor = async () => {
    const me = await getRequestWithInterceptor('users/me')
    console.log(me)
}

Note: The Axios interceptor doesn’t seem to work when used directly in page.tsx, resulting in the following error.

TypeError: _utils_axios__WEBPACK_IMPORTED_MODULE_3__.default.get is not a function

Takeaways

I had fun working on this and learnt a few new things that I hadn't noticed before:

  • CORS issues are only applicable to client components; you will never get CORS error in server components.

  • Calling functions in files marked with ‘use server’ doesn’t automatically make them server actions.

  • We cannot directly import Auth.js's signIn, signOut in client components because they use next/header, so we need to make them server actions in auth-helper.ts

These additional resources helped me with implementing my solutions.