Max Zavatyi

Implementing Custom JWT Authorization in Next.js 14-15

Nov 23, 2024

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.

Share on:

Found this article helpful? Consider buying me a coffee and sharing your thoughts – I'd love to hear from you!