Implementing Custom JWT Authorization in Next.js 14-15
The why
There are libraries for authorization, such as NextAuth, Auth0, and others. But, some require adjusting to their predefined flow, involve challenges in overriding it, or have high prices plans. Additionally, learning each library and understanding its pros and cons can be very time-consuming. Therefore, there is no need to introduce unnecessary dependencies for JWT authentication when we can handle it ourselves while maintaining maximum flexibility.
NOTE: This post focuses solely on handling authorization using a JWT token on the front-end. For this approach to work effectively, it is essential to have a back-end server that follows best practices for secure authorization, and then sends JWT tokens to the front-end.
We'll go step by step to implement the authorization, and at the end, I will provide a full code example.
Server side
Starting with the server side, we first need to create some utilities since fetching libraries like React Query or SWR cannot be used on the server. Additionally, libraries such as Axios are not supported in Next.js middleware. To maintain consistency, we’ll use the native Fetch API and create a few utilities to work with it.
Middleware
In Next.js, middleware functions allow us to execute custom logic on the server before a request is completed. This enables the modification of incoming requests and outgoing responses, including actions like rewriting URLs, redirecting users, and setting headers or cookies. Middleware operates at the edge, ensuring efficient processing and enhancing performance. It's particularly useful for tasks such as authentication, and implementing redirects, providing a centralized approach to handle these concerns across your application.
Defining authorization middleware
export async function authMiddleware(
request: NextRequest
): Promise<NextResponse | null> {
This is an asynchronous middleware function called authMiddleware
that takes a NextRequest
object as argument and returns a NextResponse
or null
.
const accessTokenCookie = request.cookies.get('accessToken');
const refreshTokenCookie = request.cookies.get('refreshToken');
const accessToken = accessTokenCookie ? accessTokenCookie.value : null;
const refreshToken = refreshTokenCookie ? refreshTokenCookie.value : null;
Then in the middleware function retrieve the cookies for accessToken
and refreshToken
, prefixed by the constant RECORD_PREFIX
, and check if they exist.
const isProtectedRoute = PROTECTED_ROUTES.some((route) =>
request.nextUrl.pathname.includes(route)
);
const isAuthRoute = AUTH_ROUTES.some((route) =>
request.nextUrl.pathname.includes(route)
);
Checks if the current request
is for a "protected route" by comparing the pathname
of the request URL against a list of routes defined in PROTECTED_ROUTES
. PROTECTED_ROUTES
should be an array of strings (e.g., ['/dashboard', '/profile']
). isProtectedRoute
will be true if the current pathname
contains any of the listed routes.
Similar to the check for protected routes, this determines if the current route is an "auth route" by comparing the pathname
against a list of routes in AUTH_ROUTES
(e.g., ['/login', '/signup']
). Will tell more about this a bit later.
const pathSegments = request.nextUrl.pathname.split('/').filter(Boolean);
const locale = pathSegments[0];
const signInUrl = new URL(`/${locale}${ROUTES.signIn}`, request.url);
Simply extract locale from the URL.
if (accessToken) {
const isAccessTokenExpired = await isTokenExpired({ token: accessToken });
if (!isAccessTokenExpired) {
if (isAuthRoute) {
return NextResponse.redirect(
new URL(`/${locale}${ROUTES.home}`, request.url)
);
}
return null;
}
}
First, we immediately check if the user has an accessToken
and if it hasn't expired. If the token is valid, we return null
to skip running other checks unnecessarily. Additionally, inside this block, we check if the user is trying to access routes such as sign-up or similar while being authorized. In such cases, we simply redirect the user to the home page.
Now, let's define a utility function to handle redirection to the sign-in page and ensure any leftover expired accessToken
and refreshToken
cookies are cleared.
export function handleServerRedirectToSignIn(signInUrl: URL) {
const nextResponse = NextResponse.redirect(signInUrl);
const cookieSettings = {
path: '/',
secure: true,
sameSite: 'strict' as const,
maxAge: 0,
};
// Clear cookies explicitly
nextResponse.cookies.set('accessToken', '', cookieSettings);
nextResponse.cookies.set('refreshToken', '', cookieSettings);
return nextResponse;
}
Middleware logic
if (isProtectedRoute) {
const isRefreshTokenExpired = refreshToken
? await isTokenExpired({ token: refreshToken })
: true;
if (isRefreshTokenExpired) {
return handleServerRedirectToSignIn(signInUrl);
}
}
In this step, we check if the incoming request is trying to access a protected route. Since we already checked for the accessToken
in the previous block, we only need to check for a refreshToken
here. If it's a protected route and the refreshToken
is missing or expired, we redirect the user to the sign-in page. This step is straightforward because we will handle token refreshing in the next step.
NOTE: To check if the token has expired, I used
jwt-decode
library and created a utility function. I won't go into too much detail here to keep this article concise.
if (refreshToken) {
const isRefreshTokenExpired = await isTokenExpired({ token: refreshToken });
if (isRefreshTokenExpired) {
return handleServerRedirectToSignIn(signInUrl);
}
try {
const refreshResponse = await refreshServerTokenRequest(refreshToken);
if (refreshResponse?.accessToken) {
const nextResponse = NextResponse.redirect(request.url);
nextResponse.cookies.set(
'accessToken',
refreshResponse.accessToken,
{
path: '/',
secure: true,
sameSite: 'strict',
maxAge: refreshResponse.accessTokenTtlMs,
}
);
return nextResponse;
}
return handleServerRedirectToSignIn(signInUrl);
} catch (error) {
return handleServerRedirectToSignIn(signInUrl);
}
}
In this final step, we check all routes to ensure the user has a refreshToken
cookie and that it hasn't expired. If the refreshToken
is expired, we redirect the user to the sign-in page immediately. Otherwise, we send this refreshToken to the back-end via a POST
request to obtain a new accessToken
.
Next, we check if the response contains an accessToken
. If it does, we set the new token in the cookies and return the response with a redirect to the requested page.
If the API response does not include a new accessToken
or if an error is caught, we redirect the user to the sign-in page.
And finally, if none of the checks above are executed, we simply return null
to allow the request to proceed or be handled by other middlewares.
Root middleware
The hardest part is done! Now, we need to define a root middleware where we call this authMiddleware
.
const i18nMiddleware = createMiddleware(routing);
export async function middleware(request: NextRequest) {
const authResponse = await authMiddleware(request);
if (authResponse) {
return authResponse;
}
const i18nResponse = i18nMiddleware(request);
if (i18nResponse) {
return i18nResponse;
}
return NextResponse.next();
}
// Add all existing routes
export const config = {
matcher: [
'/(en|ro)?/sign-in',
'/(en|ro)?/profile,
],
};
This root middleware should be placed in the project root. Here, we define a middleware function that accepts a request object, which we then pass to the middlewares we call within this root middleware.
This is an example of how to set up multiple middlewares. The order is crucial here! We must call the authMiddleware
first and check if it returns a response. If it does, we return that response. If it returns null
, we simply proceed to the next middleware, such as the i18nMiddleware
in this example.
At the end, we need to return NextResponse.next()
to allow the request to continue.
Finally, we define a config object where we specify the routes that the middlewares should run on. For the authMiddleware
, we should include all routes to handle both public and protected routes. And with that, the server-side setup is complete!
Client side
Nothing fancy here - just need to make sure to handle token refresh in scenarios where the user performs an action, such as submitting a form, that requires authorization. If the user encounters an authentication error (e.g., a 401 error) because the accessToken
has expired, we catch that error on the client, call the refresh token API, and, upon a successful refresh, retry the action. This way, the user won’t see any error or be redirected to the sign-in page, and the action is completed successfully without interrupting the flow.
Results and user experience
With our server-side authorization implementation, all checks are handled early, before the browser renders the page. This approach is highly effective because, if authorization were handled on the client while using SSR, attempting to access a protected route without authorization could result in flickering before redirecting to the sign-in page. Such behavior would not only look unprofessional but could also potentially expose protected content momentarily. By handling authorization on the server, we completely eliminate these issues.
Additionally, if the access token expires and the user navigates to another protected page, the token is seamlessly refreshed in the background without interrupting the user flow or redirecting them to the sign-in page. Similarly, on page reloads, the token is automatically refreshed in the background.
For client-side actions that require authorization, such as form submissions, the token is refreshed in the background whenever necessary. This ensures a smooth user experience, as the action is retried after refreshing the token, without showing any errors or disrupting the flow.