NestJS JWT Auth with Email Verification
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.
- 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.
- 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:
- 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.
- 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.
- 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.
- 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:
- We injected our service into the controller.
- We called our service register function inside the post request.
- We added the
@Post('register')decorator. This decorator tells NestJS that this method will handle HTTPPOSTrequests 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
- We first check if a token is provided in the request.
- We query the database to find a user that has the same email verification token.
- We check if the token has expired by comparing the current date with the expiration date stored in the database.
- We also check if the user is already verified (no need to reverify).
- 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
- We generate a random token that will be used to verify the user’s email.
- Then we create an expiration date for the token (24 hours), you can skip this step, but it’s improves security.
- 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).
- 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.