Handling Refresh Token with Next.js, Auth.js (next-auth v5) Credentials Provider, and HTTPOnly Cookie
Using Server Actions and Axios Interceptors
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:
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:
Using a request wrapper
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.
- 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
}
- 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.
- 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
- 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
}
- 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 usenext/header
, so we need to make them server actions inauth-helper.ts
Useful Links
These additional resources helped me with implementing my solutions.
Next-Auth v5 is Almost Here! Learn it Fast on the NextJS App Router TODAY!
How to send httponly cookies client side when using next-auth credentials provider?
Refresh Token Rotation With Next-Auth V5 || Managing Tokens With A Custom Backend
https://authjs.dev/getting-started/migrating-to-v5#authenticating-server-side