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

Cheryl M - Sep 30 - - Dev Community

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, Ill 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})
Enter fullscreen mode Exit fullscreen mode

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 doesnt directly set cookies in the browser.

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

Parse the cookies and set them:

// set-cookies.tsimport {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, }) }) }}
Enter fullscreen mode Exit fullscreen mode

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 },)
Enter fullscreen mode Exit fullscreen mode

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,}
Enter fullscreen mode Exit fullscreen mode

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)) }}
Enter fullscreen mode Exit fullscreen mode

export the authConfig,

const authOptions: NextAuthConfig = { session: { strategy: 'jwt' }, providers: [credentialsConfig], callbacks, secret: process.env.AUTH_SECRET,} satisfies NextAuthConfigexport const {handlers, auth, signIn, signOut} = NextAuth(authOptions)
Enter fullscreen mode Exit fullscreen mode

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()}
Enter fullscreen mode Exit fullscreen mode

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 })}
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode
  1. Using the wrapper ( event handler on page.tsx)
const getMeServer = async () => { const me = await getRequest('users/me') console.log(me)}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  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}
Enter fullscreen mode Exit fullscreen mode
  1. Using the interceptor (event handler on page.tsx)
// pages.tsxconst getMeInterceptor = async () => { const me = await getRequestWithInterceptor('users/me') console.log(me)}
Enter fullscreen mode Exit fullscreen mode

Note: The Axios interceptor doesnt 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
Enter fullscreen mode Exit fullscreen mode

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

Useful Links

These additional resources helped me with implementing my solutions.

. . . . . . . . . . . . . . . . . . . .
Terabox Video Player