Overview
The authentication flow works as follows:
User submits login form
Next.js makes a request to the
/login
API endpointNest.js generates a JWT access token and sets it as a cookie
The access token cookie is sent with subsequent requests
Next.js middleware checks for the access token cookie
If present, the request is allowed to proceed to the protected route
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.
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
Now install some packages that we will use later:
pnpm add cookie-parser
- For parsing cookiespnpm add -D @types/cookie-parser
- Type declarations for cookie-parserpnpm add @nestjs/jwt
- For creating justpnpm add dotenv
- to load environment variables
Let's add the cookie parser and cors middleware:
Inside the
src/main.ts
you should have thisimport { 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();
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
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 {}
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' }); } }
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; } }
Now let's create a protected route:
Modify
src/auth/auth.controller.ts
to look like thisimport { 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.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**.**
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
Install Dependencies
pnpm add axios
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;
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