Authentication using NextJS Client and NestJS Server

blog header image

Overview

The authentication flow works as follows:

  1. User submits login form

  2. Next.js makes a request to the /login API endpoint

  3. Nest.js generates a JWT access token and sets it as a cookie

  4. The access token cookie is sent with subsequent requests

  5. Next.js middleware checks for the access token cookie

  6. If present, the request is allowed to proceed to the protected route

  7. Otherwise, the user is redirected to the login page

This article provides a good overview of implementing authentication between a Next.js frontend and Nest.js API. The code examples demonstrate how to parse cookies, generate JWTs, validate requests and handle authentication in middleware.

Setting up NestJS

To learn how to set up your NestJS server, please visit the following URL: NestJS Documentation.

  1. Once you've created your project, navigate to the project's root folder and create a .env file, populating it with the necessary values.

     FRONTEND_URL=http://localhost:3000
     ENVIRONMENT=DEV
     JWT_SECRET=this_should_be_a_random_secure_string
    
  2. Now install some packages that we will use later:

    • pnpm add cookie-parser - For parsing cookies

    • pnpm add -D @types/cookie-parser - Type declarations for cookie-parser

    • pnpm add @nestjs/jwt - For creating just

    • pnpm add dotenv - to load environment variables

  3. Let's add the cookie parser and cors middleware:

    Inside the src/main.ts you should have this

     import { NestFactory } from '@nestjs/core';
     import { AppModule } from './app.module';
     import * as cookieParser from 'cookie-parser';
     import { config } from 'dotenv';
     config();
    
     async function bootstrap() {
       const app = await NestFactory.create(AppModule);
         app.enableCors({
         origin: process.env.FRONTEND_URL,
         credentials: true,
       });
    
       app.use(cookieParser()); // using the cookie parser middleware
       await app.listen(process.env.PORT || 3001); // 3001 because our frontend will be on port 3000
     }
     bootstrap();
    
  4. Create an authentication module and its corresponding controller responsible for user authentication in your NestJS project, you can do that by utilizing the NestJS CLI. Execute the following commands from the project's root directory:

    • nest g module auth

    • nest g controller auth --no-spec

  5. Let's import the @nestjs/jwt module in our auth module.

    src/auth/auth.module.ts should now look like this:

     import { Module } from '@nestjs/common';
     import { AuthController } from './auth.controller';
     import { config } from 'dotenv';
     config();
    
     import { JwtModule } from '@nestjs/jwt';
    
     @Module({
       controllers: [AuthController],
       imports: [
         JwtModule.register({
           global: true,
           secret: process.env.JWT_SECRET,
           signOptions: { expiresIn: '24h' },
         }),
       ],
     })
     export class AuthModule {}
    
  6. Modify src/auth/auth.controller.ts to look like this:

     import {
       Body,
       Controller,
       Post,
       Res,
       UnauthorizedException,
     } from '@nestjs/common';
     import { JwtService } from '@nestjs/jwt';
     import { Response } from 'express';
    
     const usersTable = [{ userId: 1, username: 'john', password: 'password123' }];
     // in a real world scenario it would be best to have an actiual database
     // with hashed passwords
    
     @Controller('auth')
     export class AuthController {
       constructor(private readonly jwtService: JwtService) {}
       @Post('login')
       async login(
         @Body() userCredentials: { username: string; password: string },
         @Res() res: Response,
       ) {
         const existingUser = usersTable.find(
           (user) => user.username === userCredentials.username,
         );
    
         // if user doesnt exist or passwords dont match
         if (
           !existingUser ||
           !(existingUser.password === userCredentials.password)
         ) {
           throw new UnauthorizedException();
         }
         const access_token = await this.jwtService.signAsync({
           sub: existingUser.userId,
         });
         return res
           .cookie('access_token', access_token, {
             httpOnly: true, // we dont want client side code to access the cookie
             secure: !(process.env.ENVIRONMENT === 'DEV'), // in development mode will be sending requests over http
             sameSite: 'none', // none because our client will exist on a different domain
             expires: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
           })
           .json({ success: true, message: 'User logged in' });
       }
     }
    
  7. Create a new file src/auth/auth.guard.ts that will be responsible for validating requests before reaching protected routes.

     import {
       CanActivate,
       ExecutionContext,
       Injectable,
       UnauthorizedException,
     } from '@nestjs/common';
     import { JwtService } from '@nestjs/jwt';
     import { config } from 'dotenv';
     config();
    
     import { Request } from 'express';
    
     @Injectable()
     export class AuthGuard implements CanActivate {
       constructor(private jwtService: JwtService) {}
    
       async canActivate(context: ExecutionContext): Promise<boolean> {
         const request = context.switchToHttp().getRequest(); // get the request
         // extract token from request cookie
         const token = this.extractTokenFromCookie(request);
    
         // validate token
         try {
           const payload = await this.jwtService.verifyAsync(token, {
             secret: process.env.JWT_SECRET,
           });
           if (!payload.sub) {
             throw new UnauthorizedException();
           }
           request.userId = payload.sub;
         } catch {
           throw new UnauthorizedException();
         }
         return true;
       }
    
       private extractTokenFromCookie(request: Request): string | undefined {
         const token = request.cookies.access_token;
         return token;
       }
     }
    
  8. Now let's create a protected route:

    Modify src/auth/auth.controller.ts to look like this

     import {
       Body,
       Controller,
       Get,
       Post,
       Req,
       Res,
       UnauthorizedException,
       UseGuards,
     } from '@nestjs/common';
     import { JwtService } from '@nestjs/jwt';
     import { Response, Request } from 'express';
     import { AuthGuard } from './auth.guard';
    
     const usersTable = [{ userId: 1, username: 'john', password: 'password123' }];
     // in a real world scenario it would be best to have an actiual database
     // with hashed passwords
    
     @Controller('auth')
     export class AuthController {
       constructor(private readonly jwtService: JwtService) {}
       @Post('login')
       async login(
         @Body() userCredentials: { username: string; password: string },
         @Res() res: Response,
       ) {
         const existingUser = usersTable.find(
           (user) => user.username === userCredentials.username,
         );
    
         // if user doesnt exist or passwords dont match
         if (
           !existingUser ||
           !(existingUser.password === userCredentials.password)
         ) {
           throw new UnauthorizedException();
         }
         const access_token = await this.jwtService.signAsync({
           sub: existingUser.userId,
         });
         return res
           .cookie('access_token', access_token, {
             httpOnly: true, // we dont want client side code to access the cookie
             secure: !(process.env.ENVIRONMENT === 'DEV'), // in development mode will be sending requests over http
             sameSite: 'none', // none because our client will exist on a different domain
             expires: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
           })
           .json({ success: true, message: 'User logged in' });
       }
    
       @UseGuards(AuthGuard)
       @Get('isAuthenticated')
       async isAuthenticated() {
         return { success: true };
       }
     }
    

    We added a protected route isAuthenticated that returns if the user is logged in.

  9. Awesome, now that we're finished with setting up NestJS, let's head over to the client side.

Setting up NextJS

To learn how to set up your NextJS application, please visit the following URL: NextJS Documentation**.**

  1. Once you've created your project, navigate to the project's root folder and create a .env file, populating it with the necessary values.

     NEXT_PUBLIC_BACKEND_URL=http://localhost:3001
    
  2. Install Dependencies

    • pnpm add axios
  3. Create a login component at src/components/LoginForm.tsx

     "use client";
     import React, { useState } from "react";
     import { useRouter } from "next/navigation";
     import axios from "axios";
    
     const Login: React.FC = () => {
       const [username, setUsername] = useState("");
       const [password, setPassword] = useState("");
       const router = useRouter();
    
       const handleUsernameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
         setUsername(e.target.value);
       };
    
       const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
         setPassword(e.target.value);
       };
    
       const handleSubmit = async (e: React.FormEvent) => {
         e.preventDefault();
    
         const data = { username, password };
    
         try {
           const response = await axios.post(
             process.env.NEXT_PUBLIC_BACKEND_URL + "/auth/login",
             data,
             { withCredentials: true }
           );
    
           // Handle successful login
           alert("Login successful");
           router.push("/dashboard");
         } catch (error) {
           // Handle network or other errors
           console.error("Error:", error);
         }
       };
    
       return (
         <div>
           <h2>Login</h2>
           <form onSubmit={handleSubmit}>
             <div>
               <label htmlFor="username">Username:</label>
               <input
                 type="text"
                 id="username"
                 name="username"
                 value={username}
                 onChange={handleUsernameChange}
               />
             </div>
             <div>
               <label htmlFor="password">Password:</label>
               <input
                 type="password"
                 id="password"
                 name="password"
                 value={password}
                 onChange={handlePasswordChange}
               />
             </div>
             <button type="submit">Login</button>
           </form>
         </div>
       );
     };
    
     export default Login;
    
  4. Create the following files:

     // src/app/auth/log-in/page.tsx
     import Login from '@/components/LoginForm'
     import React from 'react'
    
     export default function page() {
       return (
         <div>
           <Login/>
         </div>
       )
     }
    
     // src/app/dashboard/page.tsx
     import React from 'react'
    
     export default function page() {
       return (
         <div>
           This page is protected
         </div>
       )
     }
    
     // src/middleware.ts
    
     import { NextResponse } from "next/server";
     import type { NextRequest } from "next/server";
    
     const publicRoutes: string[] = ["/"] as const;
    
     export default async function middleware(request: NextRequest) {
       //  if auth route and authenticated redirect to dashboard
       if (request.nextUrl.pathname.startsWith("/auth")) {
         if (await isAuthenticated(request.cookies.get("access_token")?.value)) {
           return NextResponse.redirect(new URL("/dashboard", request.url));
         }
         return;
       }
       const isAuth = await isAuthenticated(
         request.cookies.get("access_token")?.value
       );
       const isPublic = isPublicRoute(request.nextUrl.pathname);
    
       // if not a public and user is not authenticated then redirect to log in
       if (!isPublic && !isAuth) {
         return NextResponse.redirect(new URL("/auth/log-in", request.url));
       }
     }
    
     function isPublicRoute(pathname: string): boolean {
       for (let route of publicRoutes) {
         if (route === pathname) {
           return true;
         }
       }
    
       return false;
     }
    
     // do not run for static files
     export const config = {
       matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
     };
    
     export async function isAuthenticated(acc_token: string | undefined) {
       const data = await fetch(
         `${process.env.NEXT_PUBLIC_BACKEND_URL}/auth/isAuthenticated`,
         {
           credentials: "include",
           headers: {
             Cookie: `access_token=${acc_token};`,
           },
         }
       );
    
       if (data.status >= 400) {
         return false;
       }
       return true;
     }
    
     // src/app/page.tsx
     import { isAuthenticated } from "@/middleware";
     import { cookies } from "next/headers";
     import Link from "next/link";
    
     export default async function Home() {
       const auth = await isAuthenticated(cookies().get("access_token")?.value);
       return (
         <main className="">
           {auth ? (
             <Link href="/dashboard">Dashboard</Link>
           ) : (
             <Link href="/auth/log-in">Log In</Link>
           )}
         </main>
       );
     }
    

We are finished, time to test the functionality

This Set-Cookie didn't specify a "SameSite" attribute and was default to "SameSite=Lax" - Localhost you will get this error when in development mode and using the chrome browser. An alternative solution is to use Firefox and set: about:config > network.cookie.sameSite.noneRequiresSecure=false. This allows SameSite=None; Secure=false
Test your application by running pnpm run dev for NextJS and pnpm run start:dev for NestJS

Thanks for reading 😁

Stephan Mingoes © 2024