NestJS JWT Auth with Email Verification

Houda Debza Houda Debza 15 min read

Introduction

Authentication is a feature that exists in most of the applications, so as a backend or full-stack developer, you need to implement it properly, not just “make it work”.

It’s one of the first features we build, and one of the easiest to get wrong. Especially at the beginning, a simple setup might seem sufficient but if not validated and structured, it can quickly open security issues and unreliable user flows.

There are lots of authentication strategies out there, and each has its own pros and cons. In this article, we will focus on one of the most popular approaches: JWT authentication.

We won’t just output a token and stop. Instead, we’re going to build a fuller flow with email verification. This makes sure that users really own the email addresses they sign up with, which is a necessary step in most real world applications.

The idea is to keep the implementation simple, but structured in a way that resembles how authentication is done in production systems.

What are we going to do

We are going to build a JWT auth in NestJS and React using passport-jwt and send a validation email to validate the user account using Resend.

Here is the pipeline we will follow:

[ User ]

   │ Register

[ Backend ]

   │ Create account (unverified)
   │ Send verification email

[ Email Inbox ]

   │ Click verification link

[ Backend ]

   │ Validate account

[ User ]

   │ Login

[ Backend ]

   │ Generate JWT

[ Authenticated User ]

In this first part we will only focus on the user registry, the envirenment setup and the email verification, we will implement the login in the second part.

Setting up the environment

To achieve this, we should first set up our project; in my case, I’m going to use a monorepo setup using Turborepo (you can also use separate projects for the frontend and backend). If you have any struggle setting up the monorepo, this article will help you: Setting Up TurboRepo Is Easy… Until You Add Another App

Create The Database module and the user schema

Initiating the Database

You can literally store your users in a table in memory, in a JSON file, or in any storage you prefer; for this example, I’m going to use a MySQL database with Drizzle as an ORM (feel free to use Prisma, TypeORM, …).

First let’s install the necessary dependencies:

cd apps/api
pnpm install drizzle-orm mysql2 dotenv @nestjs/config drizzle-kit

Create a .env file:

## .env
DATABASE_URL="mysql://root:root@localhost:3306/auth"
## the first root stands for the database user
## the second root stands for the database password
## auth is our database name
## 3306 is the database port

You can now create your database manually (either locally or using Docker) using your corresponding credentials, or you can make a Docker Compose file to create it and start it in the future (yes, I get you have too many steps to start, but it’s a good future optimization).

Here is an example of the Docker Compose file (you can either create it in the project or even in another location):

## docker-compose.yaml
services:
  db:
    image: mysql:9.7
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: auth
    ports:
      - '3306:3306'
    volumes:
      - mysql_data:/var/lib/mysql
    healthcheck:
      test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-uroot', '-proot']
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  mysql_data:

and run it:

docker-compose up -d

Creating the database module

Now, our database is ready. We can create our database module by running the following:

nest g module db

The generated module code:

// db.module.ts
import { Module } from  '@nestjs/common';
@Module({
	providers:  []
})
export  class  DbModule {}

Since most of the future modules will depend on the database, we’ll add the @Global() decorator at the top of this module. This allows us to access it anywhere in the application without having to import it in every module.

Then we should define what this module will do in the providers:

// db.module.ts
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';

@Global()
@Module({
  providers: [
    {
      inject: [ConfigService],
      useFactory: async (configService: ConfigService) => {
        const connection = await mysql.createConnection(
          configService.get<string>('DATABASE_URL')!
        );

        return drizzle(connection, {
          mode: 'default', 
        });
      },
    },
  ],
})
export class DatabaseModule {}

Our module uses the useFactory method, which allows us to dynamically create the database connection based on the value present in the .env file.

Finally, we need to give it a provider name, which NestJS will use to inject it in the other modules automatically, and then export the module.

The final database module:

// db.module.ts
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';

export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';

@Global()
@Module({
  providers: [
    {
      provide: DATABASE_CONNECTION,
      inject: [ConfigService],
      useFactory: async (configService: ConfigService) => {
        const connection = await mysql.createConnection(
          configService.get<string>('DATABASE_URL')!
        );

        return drizzle(connection, {
          mode: 'default', 
        });
      },
    },
  ],
  exports: [DATABASE_CONNECTION],
})
export class DatabaseModule {}

But if you try to start your application and hope that everything will work, I’m sorry to say it, but it won’t since you have to register the config module in the app module (because we used it in the database module), like this:

// app.module.ts
import { Module } from  '@nestjs/common';
import { AppController } from  './app.controller';
import { AppService } from  './app.service';
import { DatabaseModule } from  './db/db.module';
import { ConfigModule } from  '@nestjs/config';

@Module({
	imports:  [
		ConfigModule.forRoot({
			isGlobal: true,
		}),
		DatabaseModule,
	],
	controllers:  [AppController],
	providers:  [AppService],
})
export  class  AppModule {}

Now you can run it and see that everything is going well; let’s move to the next step.

Creating the database Schema

Right now we have a project and a database that are connected together, but we need a table in that database to store the users, so let’s do it.

  1. We need to create a schema file under the db module folder, like this:
// schema.ts
import {
	mysqlTable,
	varchar,
	boolean,
	timestamp,
	mysqlEnum,
} from  'drizzle-orm/mysql-core';

export  const  users  =  mysqlTable('users', {
	id:  varchar('id', { length:  36 }).primaryKey(),
	email:  varchar('email', { length:  255 }).unique().notNull(),
	password:  varchar('password', { length:  255 }).notNull(),
	role:  mysqlEnum('role', ['seller', 'customer']).notNull(),
	isVerified:  boolean('is_verified').default(false).notNull(),
	emailVerificationToken:  varchar('email_verification_token', { length:  255 }),
	emailVerificationExpires:  timestamp('email_verification_expires'),
	createdAt:  timestamp('created_at').defaultNow().notNull(),
});
export  type  User  =  typeof  users.$inferSelect;
export  type  InsertUser  =  typeof  users.$inferInsert;

If you noticed, I added a user type (I reused it from a marketplace project), so you can better understand role-based authentication.

  1. We need to add it into the module provider:
// db.module.ts
return drizzle(connection, {
		schema,
		mode: 'default', 
});

Before we can generate and apply this migration. We should first create a drizzle config file, since drizzle doesn’t know about your NestJS code; the config will allow it to know where your schema is and where to create the migration files. You need to create it in your backend root folder; it will contain the following:

// drizzle.config.ts
import  'dotenv/config';
import { defineConfig } from  'drizzle-kit';

export  default  defineConfig({
	dialect:  'mysql',
	schema:  './src/db/schema.ts', // make sure it matches your schema file path
	out:  './drizzle',
	dbCredentials: {
		url:  process.env.DATABASE_URL!,
	},
});

Then add migrate and generate the script into the package.json (you can run the Drizzle commands directly also, but if you are going to work in a team, it’s better to treat the ORM as a black box).

// packages.json
"scripts": {
	...
	"db:generate": "drizzle-kit generate",
	"db:migrate": "drizzle-kit migrate"
},

And now we can run those two scripts in our terminal:

pnpm db:generate
pnpm db:migrate

If you are not sure whether the migration is applied, you can verify the tables of the database before we move to the auth part.

Authentication module

So, before starting the authentication module, let’ install the required dependencies:

cd apps/api
pnpm install bcryptjs passport passport-jwt @nestjs/jwt @nestjs/swagger zod
## bcryptjs => hash password
## passport, passport-jwt, @nestjs/jwt => generate and manage the user access token
## swagger => a UI to test and document the API (you can skip it)
## zod => schema validation for incoming data

Now let’s generate the module, service, and controller of our auth module using the Nest CLI:

nest g module auth  
nest g controller auth  
nest g service auth

And create our register’s incoming data schema validation using Zod.

/* 
You can either: 
- create a validation folder in the backend root folder and put this file in it 
- or create it in the auth folder, 
- or if you are working under a monorepo, you can add a validation package that allows you to share the types between the frontend and backend and guarantees the consistency. 
- To make it easy to follow, I will create a validation folder in our backend and add this file into it.
*/
// auth.ts
import { z } from  'zod';
export  const  RegisterSchema  =  z.object({
	email:  z.string().email('Invalid email address'),
	password:  z.string().min(8, 'Password must be at least 8 characters'),
	role:  z.enum(['seller', 'customer'], { error:  'Role must be seller or customer' }),
});
export  type  RegisterInput  =  z.infer<typeof  RegisterSchema>;

Then create the register service:

// auth.service.ts
import { ConflictException, Injectable, Inject } from  '@nestjs/common';
import { eq } from  'drizzle-orm';
import * as  bcrypt  from  'bcryptjs';
import { users } from  '../db/schema';
import { DATABASE_CONNECTION } from  'src/db/db.module';
import { RegisterInput } from  '../../validation/auth';
import { randomBytes } from  'crypto';

@Injectable()
export  class  AuthService {
	constructor(@Inject(DATABASE_CONNECTION) private  readonly  db:  any) {}
	
	async  register(input:  RegisterInput):  Promise<{ message:  string }> {
		// check if the user already exists
		const  existingUser  =  await  this.db
			.select()
			.from(users)
			.where(eq(users.email, input.email))
			.limit(1);
		if (existingUser.length  >  0) {
			throw  new  ConflictException('User with this email already exist');
		}
		
		// hash the password
		const  hashedPassword  =  await  bcrypt.hash(input.password, 12);
		// generate user ID
		const  userId  =  randomBytes(16).toString('hex');

		//Insert the user into the database
		await  this.db.insert(users).values({
			id:  userId,
			email:  input.email,
			password:  hashedPassword,
			role:  input.role,
			isVerified: false,
			emailVerificationToken: null,
			emailVerificationExpires: null,
		});
		return {
			message:  'Registration successful',
		};
	}
}

Let’s break down this code:

  1. If you remember the database module we created and set here, we injected it with its provider name; I guess now it’s clearer why we added that const.
  2. We are checking if a user with the same email already exists in the db; if so, the user should be redirected to the login page.
  3. If the user doesn’t exist, then we should create it; we start by hashing the password and generating an ID for this new user.
  4. Then we call our database to create an entry for this new user (the emailVerificationToken and emailVerificationExpires are set to null right now; we will update this when adding the email verification logic).

We finally need the register controller to handle the HTTP requests:

// auth.controller.ts
import { Body, Controller, Post } from  '@nestjs/common'; 
import { AuthService } from  './auth.service';
import  type { RegisterInput } from  'validation/auth';

@Controller('auth')
export  class  AuthController {
constructor(private  readonly  authService:  AuthService) {}
	@Post('register')
	async  register(@Body() body:  RegisterInput) {
		return  this.authService.register(body);
	}
}

Let’s explain this code:

  1. We injected our service into the controller.
  2. We called our service register function inside the post request.
  3. We added the @Post('register') decorator. This decorator tells NestJS that this method will handle HTTP POST requests sent to /auth/register

You can open Postman and test this endpoint, or as a bonus step, you can set up the Swagger UI in the main.ts

// main.ts
import { NestFactory } from  '@nestjs/core';
import { AppModule } from  './app.module';
import { SwaggerModule, DocumentBuilder } from  '@nestjs/swagger';

async  function  bootstrap() {
	const  app  =  await  NestFactory.create(AppModule);

	const  config  =  new  DocumentBuilder()
		.setTitle('API')
		.setDescription('WeStore marketplace API')
		.setVersion('1.0')
		.addBearerAuth()
		.addTag('Authentication', 'User registration')
		.build();

	const  document  =  SwaggerModule.createDocument(app, config);
	SwaggerModule.setup('docs', app, document);

	await  app.listen(process.env.PORT  ??  4000);
}
bootstrap();

And then add the appropriate decorators of swagger into your controller; it’s a long list of decorators, so check my repo if you want to go further with swagger: jwt-auth-monorepo-nest-react

Now run the application and test the register endpoint with this json format:

{
  "email": "user@example.com",
  "password": "securePassword123",
  "role": "customer"
}

Everything works fine.

Email verification

I guess that you remember this in the auth service:

// auth.service.ts
//Insert the user into the database
await  this.db.insert(users).values({
	id:  userId,
	email:  input.email,
	password:  hashedPassword,
	role:  input.role,
	isVerified: false,
	emailVerificationToken: null,
	emailVerificationExpires: null,
});

We initially set the email verification token and its expiration to null since we hadn’t implemented the verification logic yet. Now, let’s add that functionality.

Install the dependencies

First let’s start by installing the needed dependencies:

pnpm install resend

Getting an API key

Let’s go to resend and create a new API key.

Once you get it, let’s add it into the .env file:

// .env
DATABASE_URL=mysql://root:root@localhost:3306/auth

RESEND_API_KEY=re_..........
FROM_EMAIL=onboarding@resend.dev
## In development envirenment, emails are automatically sent from Resend’s default email. You can customize the sender address in production once you’ve added your domain.

Email module

Now let’s generate a module and a service for our email sending verification process.

nest g module email
nest g service email

In the service let’s add this.

//email.service.ts
import { Injectable } from  '@nestjs/common';
import { Resend } from  'resend';

@Injectable()
export  class  EmailService {
	private  readonly  resend  =  new  Resend(process.env.RESEND_API_KEY);
	async  sendVerificationEmail(email:  string, token:  string):  Promise<void> {
	const  verificationUrl  =  `${process.env.API_URL ?? 'http://localhost:4000'}/auth/verify-email?token=${token}`;
	const { error } =  await  this.resend.emails.send({
		from:  'auth <onboarding@resend.dev>',
		to:  email,
		subject:  'Verify your auth account',
		html:  `
			<div style="font-family:sans-serif;max-width:480px;margin:0 auto;padding:32px 24px">
				<h2 style="color:#09B1BA;margin:0 0 8px">Auth</h2>
				<p style="color:#111;font-size:16px;font-weight:600;margin:0 0 16px">Confirm your email address</p>
				<p style="color:#555;font-size:14px;line-height:1.6;margin:0 0 24px">
					Click the button below to verify your account. This link expires in 24 hours.
				</p>
				<a href="${verificationUrl}"
					style="display:inline-block;background:#09B1BA;color:#fff;text-decoration:none;padding:12px 28px;border-radius:8px;font-size:14px;font-weight:600">
					Verify email
				</a>
				<p style="color:#aaa;font-size:12px;margin:24px 0 0">
					If you didn't create an account, you can safely ignore this email.
				</p>
			</div>
			`,
	});

		if (error) {
			console.error('Resend error:', error);
			throw  new  Error(`Failed to send verification email: ${error.message}`);
		}
	}
}

This is a UI of the verification email that will be sent to the user (you can customize the UI).

If you noticed, there is verificationUrl that which takes a token, so let’s go create the email verification logic that generates this token.

Email verification, auth service

Go back to the auth service; let’s define the email verification function:

async  verifyEmail(token:  string):  Promise<{ message:  string }> {
	if (!token) {
		throw  new  BadRequestException('Verification token is required');
	}

	const  user  =  await  this.db
		.select()
		.from(users)
		.where(eq(users.emailVerificationToken, token))
		.limit(1);
		
	if (user.length  ===  0) {
		throw  new  NotFoundException('Invalid or expired verification token');
	}
	const  userRecord  =  user[0];
	if (
		userRecord.emailVerificationExpires  &&
		new  Date() >  userRecord.emailVerificationExpires
	) {
		throw  new  BadRequestException('Verification token has expired');
	}
	if (userRecord.isVerified) {
		throw  new  BadRequestException('Email is already verified');
	}
	await  this.db
		.update(users)
		.set({
			isVerified: true,
			emailVerificationToken: null,
			emailVerificationExpires: null,
		})
	.where(eq(users.id, userRecord.id));
	return {
		message:  'Email verified successfully. You can now log in.',
	};
}

let’s explain this code

  1. We first check if a token is provided in the request.
  2. We query the database to find a user that has the same email verification token.
  3. We check if the token has expired by comparing the current date with the expiration date stored in the database.
  4. We also check if the user is already verified (no need to reverify).
  5. If everything is valid, we mark the user as verified and remove the verification token and its expiration date (preventing reuse).

But it’s checking a token that we didn’t generate; let’s add the generation logic in the register service function:

// auth.service.ts

// add this line to the constructor private  readonly  emailService:  EmailService,

// Note: in production ot's recommended to hash the email verification token, it's just a simplification for the tutaorial purpose.
const  emailVerificationToken  =  randomBytes(32).toString('hex');
const  emailVerificationExpires  =  new  Date(Date.now() +  24  *  60  *  60  *  1000); // 24 hours

const  userId  =  randomBytes(16).toString('hex');

await  this.emailService.sendVerificationEmail(
	input.email,
	emailVerificationToken,
);

await  this.db.insert(users).values({
	id:  userId,
	email:  input.email,
	password:  hashedPassword,
	role:  input.role,
	isVerified: false,
	emailVerificationToken,
	emailVerificationExpires,
});

I guess that this code need some clarification

  1. We generate a random token that will be used to verify the user’s email.
  2. Then we create an expiration date for the token (24 hours), you can skip this step, but it’s improves security.
  3. We store both the token and its expiration date in the database along with the user’s information (you remeber that in the verification we were checking the token).
  4. After saving the user, we send a verification email that contains a link with the token. When the user clicks this link, it will trigger the verification process.

Some side note:

  • since we imported the email service in the auth service you need to make sure that you exported the email service in it’s module and imported it in the auth module.

Test the email verification

First let add the controller endpoint that will allow us to test

@Get('verify-email')
async  verifyEmail(@Query('token') token:  string) {
	return  this.authService.verifyEmail(token);
}

Then let’s create a new user with the email you used to authenticate to Resend and create the api key, since in dev mode it allows sending email only to that email.

In my side I checked that the email is sent, so it’s working.

Conclusion

Thanks for reading this article; the second part will follow soon.

You can find the boilerplate of the full pipeline (with login and UI included) in my GitHub repo: GitHub

If you have any question or want to hire me, my contact infos are available on my blog/portfolio.

Enjoyed this article?

I'm currently open to new roles, I'd love to hear from you.

Send an Email Connect on LinkedIn
Houda Debza

Written by

Houda Debza

Full Stack Developer with expertise in JavaScript/TypeScript, React, Next.js, Node.js, and NestJS, specializing in building scalable web applications and RESTful APIs. Experienced in designing microservices architectures, implementing authentication and authorization systems, and developing real-time applications. Strong background in performance optimization, API design, and distributed systems, with experience in Agile environments, version control (Git), and collaborative software development.