Implementing Authentication in Next.js: A Step-by-Step Guide [REPO]

In today’s article, we will dive into the world of Next.js to understand how to implement authentication so we can securely connect to our API.

Our goal is to establish a robust and secure system that protects our data and that of our users.

To achieve this goal, we will start by implementing middleware. This middleware will act as a gatekeeper, protecting our routes and ensuring that only authenticated users have access to them. This is a crucial step to ensure that our app is only accessible to those who have permission to use it.

Next, we will create the login and registration forms. These forms are the front door for our users, so it is essential that they provide a smooth and secure user experience. We’ll explain how to design these forms to ensure they are easy to use and meet our security needs.

Finally, we will save an encrypted cookie in the user’s browser. This cookie will be the key that will allow our authenticated users to make API calls. Ensuring this cookie is properly encrypted is vital to maintaining the security of our users and our application.

You can see the project deployed on Codesandbox and the public repo on GitHub:

https://codesandbox.io/p/github/juannunezblasco/next-auth/main

https://github.com/juannunezblasco/next-auth

How to configure authentication with Next.js

1. Protect routes

// middleware.ts 
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|assets|images|styles|icons|favicon.ico|sw.js).*)']
}

export async function middleware (request: NextRequest) {

  const cookie = request.cookies.get('accessToken')?.value ?? ''

  const token = await decrypt(cookie);

  if (!/^\/auth\//.test(request.nextUrl.pathname) && !token) {
    console.log('redirecting to login')
    return NextResponse.redirect(new URL('/auth/login', request.url))
  }
  
  if (/^\/auth\//.test(request.nextUrl.pathname) && token) {
    return NextResponse.redirect(new URL('/', request.url))
  }

  return NextResponse.next()

}

The code block above shows how middleware can be implemented in Next.js to protect application routes. In this case, the middleware checks if a cookie called ‘accessToken’ exists in the user’s browser.

If the cookie exists and is encrypted, the user can continue browsing the application. If the cookie does not exist or is not encrypted, the middleware redirects the user to the login page.

This middleware also verifies that if a user is already authenticated (i.e. has a token), they cannot access authentication routes (such as login), redirecting them to the main page instead.

2. Cookie Encryption

// middlewares/auth/token.ts
'use server';

import { JWTPayload, SignJWT, jwtVerify } from "jose";

const COOKIE_SECRET_KEY = 'nPt1cx%VyphXrz8waX#2*KW%ZoCI1S8oymrv%78Mnr'; // Change this to your own secret key come from .env file
const key = new TextEncoder().encode(COOKIE_SECRET_KEY);

export async function encrypt(payload: JWTPayload, expires: Date) {

  return await new SignJWT(payload)
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime(new Date( expires ))
    .sign(key);
}

export async function decrypt(input: string): Promise<JWTPayload | null> {

  try {
    const { payload } = await jwtVerify(input, key, {
      algorithms: ["HS256"],
    });
    return payload;
    
  } catch (error) {
    return null;
  }
}

This code block shows how to encrypt and decrypt a cookie in Next.js to keep user information secure. The encrypt method takes a payload and an expiration date as arguments, creates a new JWT, and signs it. The decrypt method takes an input string, verifies the JWT, and returns the payload if the verification is successful. On error, it returns null. The encryption key COOKIE_SECRET_KEY must be a secure and unique string.

3. Forms, validations and actions

// modules/auth/components/sign-in-form.tsx
'use client'

import { useFormState } from "react-dom";

import { ButtonSubmit } from "@/modules/common/components/molecules/button-submit";
import { signInAction } from "../../actions/sign-in";
import { FalRightToBranket } from "@/assets/icons/fal-right-to branket";

export const SignInForm = () => {

  const [state, formAction] = useFormState(signInAction, undefined); 
  
  return (
    <form 
      id="sign-in-form"
      data-testid="sign-in-form"
      className="flex flex-col gap-4 w-80 max-w-full"
      action={formAction}
    >
      <div className='flex flex-col'>
        <label htmlFor="email">Email</label>
        <input 
          id="email" 
          name="email"
          placeholder="Email" 
        />
      </div>
      {state?.errors?.email && <p>{state.errors.email}</p>}
      <div className='flex flex-col'>
        <label htmlFor="password">Password</label>
        <input 
          id="password" 
          name="password"
          type="password" 
          placeholder="Password"
        />  
      </div>
      {state?.errors?.password && (
        <div>
          <p>Password must:</p>
          <ul>
            {state.errors.password.map((error) => (
              <li key={error}>- {error}</li>
            ))}
          </ul>
        </div>
      )}
      <div className="mb-4 w-full flex justify-center">
        <ButtonSubmit>
          <FalRightToBranket className="w-5 h-auto mr-2" />
          Login
        </ButtonSubmit>
      </div>
    </form>
  )
}

The code block above represents a login form in Next.js. Use the useFormState hook to handle the form state and the signInAction login action. The form consists of two fields: Email and Password, each with its corresponding label and a space to show validation errors. Finally, there is a submit button to submit the form.

// modules/auth/actions/sign-in.ts
'use server '

import { SigninFormSchema, SigninFormState } from "../domain/sign-in-form-schema";
import { setAuthCookie } from "../hooks/use-set-cookie";

export const signInAction = async (state: SigninFormState, formData: FormData) => {
  // Validate form fields
  const validatedFields = SigninFormSchema.safeParse({
    email: formData.get('email'),
    password: formData.get('password'),
  })
  
  // If any form fields are invalid, return early
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    }
  }

  // Call the provider or db to signin a user...

  const tomorrow = new Date();
  tomorrow.setDate(tomorrow.getDate() + 1);

  // Use the `tomorrow` date in your code
  setAuthCookie({
    token: 'access-token', // This should be the token you get from the server
    expiresAt: tomorrow
  });

}

The code block above shows how to handle the login action in Next.js. First, validate the form fields using a defined schema (SigninFormSchema). If any field is invalid, an object is returned with the corresponding validation errors.

// modules/auth/domain/sign-in-form-schema.ts

import { z } from 'zod'
 
export const SigninFormSchema = z.object({
  email: z.string().email({ message: 'Please enter a valid email.' }).trim(),
  password: z
    .string()
    .min(8, { message: 'Be at least 8 characters long' })
    .regex(/[a-zA-Z]/, { message: 'Contain at least one letter.' })
    .regex(/[0-9]/, { message: 'Contain at least one number.' })
    .regex(/[^a-zA-Z0-9]/, {
      message: 'Contain at least one special character.',
    })
    .trim(),
})
 
export type SigninFormState =
  | {
      errors?: {
        email?: string[]
        password?: string[]
      }
      message?: string
    }
  | undefined

If all fields are valid, a call is made to the provider or database to log in a user (this part of the code is not shown).

Finally, an authentication cookie (setAuthCookie) is set with a token (in this example, a text string ‘access-token’, but in a real case, it would be the token that is obtained from the server) and a date of expiration (in the example, the day after the current one).

// modules/auth/hooks/use-set-cookie.tsx
'use server'

import { encrypt } from '@/middlewares/auth/token';
import { cookies } from 'next/headers';
import { redirect} from 'next/navigation';

export const setAuthCookie = async (state: { token: string, expiresAt: Date }) => {

  const encryptedToken = await encrypt( {
    ...state
  }, state.expiresAt )

  cookies().set({
    name: 'accessToken',
    value: encryptedToken,
    expires: state.expiresAt,
    path: '/',
    sameSite: 'strict',
    secure: process.env.NODE_ENV === 'production',
  })

  redirect('/')

}

The code block above shows how to set an authentication cookie in Next.js. First, encrypt the token and expiration date with the encrypt function. Then you set the cookie with the name ‘accessToken’, the encrypted token as value, the expiration date, the path ‘/’, a strict ‘sameSite’ policy, and a secure configuration if the environment is production. Finally, it redirects the user to the main page.

4. Tests

Basic configuration for testing with Jest and Testing Library.

// __tests__/app/auth/login.test.txs

import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import Login from '@/app/auth/login/page'
import { SignInForm } from '@/modules/auth/components/sign-in-form/sign-in-form'
 


// Mock the component you want to test if it's rendered
jest.mock('@/modules/auth/components/sign-in-form/sign-in-form', () => {
  return {
    __esModule: true,
    SignInForm: () => {
      return <div>Mocked Component</div>
    },
  }
})

describe('Login page', () => {
  it('renders a heading', () => {
    render(<Login />)
 
    const heading = screen.getByRole('heading', { level: 1 })
 
    expect(heading).toBeInTheDocument()
  })

  it('renders the SignInForm component', () => {
    render(<Login />)
 
    const signInForm = screen.getByText('Mocked Component')
 
    expect(signInForm).toBeInTheDocument()
  })
  
})

The code block above corresponds to a set of unit tests for a login page in Next.js, using the Jest and Testing Library libraries.

It starts by importing the necessary libraries and the component to be tested. jest.mock is then used to create a ‘mock’ of the SignInForm component, which is rendered as a div with the text ‘Mocked Component’.

The test suite contains two tests:

  1. The first test verifies that the login page renders a header. To do this, render the Login component and then look for an element with the header role at level 1. If this element is in the document, the test passes.
  2. The second test verifies that the SignInForm component is rendered on the login page. Render the component Login and then look for an element with the text ‘Mocked Component’. If this element is in the document, the test passes.

Conclusions

This article provides a basic example of how to implement authentication in Next.js. It ranges from protecting routes, encrypting cookies, creating login and registration forms, to performing tests.

If it has been helpful to you, I encourage you to share it on your networks.

Juan Núñez Blaco

Full Stack Developer