Build secure SMS-based Two-Factor Authentication (2FA) in NestJS with Twilio. This comprehensive tutorial covers OTP phone verification, JWT authentication, Prisma ORM with PostgreSQL, bcrypt password hashing, and production-ready security practices including rate limiting and environment variable protection.
Last Updated: January 2025
What You'll Build
Build a secure NestJS backend application featuring:
User Sign-up: Register users with email, phone number, and password
User Login: Authenticate with email/password and receive a JSON Web Token (JWT)
Phone Number Verification: Verify phone numbers via SMS OTP before enabling 2FA
2FA Enable/Disable: Secure endpoints for users to opt-in or opt-out of 2FA with OTP confirmation
2FA-Protected Login: Require SMS OTP after password verification when 2FA is enabled
Problem Solved: Add a critical security layer beyond passwords, mitigating risks from compromised credentials by requiring access to the user's registered phone.
Technologies Used:
NestJS: Progressive Node.js framework for building efficient, scalable server-side applications. Provides modular architecture, built-in features (pipes, guards, modules), and TypeScript support.
PostgreSQL: Powerful, open-source object-relational database system. Provides reliability and robustness.
Prisma: Modern database toolkit for Node.js and TypeScript. Simplifies database access with type safety, excellent developer experience, and migration management.
JWT (JSON Web Tokens): Stateless session management after successful authentication.
bcrypt: Secure password hashing library.
Joi: Data validation library.
System Architecture:
Your system comprises these interacting components:
Client (Browser/App): Interacts with the NestJS API
NestJS API: Handles incoming requests, business logic, validation, authentication, and coordinates with Prisma and Twilio. Contains Controllers, Services, Guards, and Pipes.
Prisma ORM: Manages database connections, queries, and migrations based on the defined schema. Acts as the data access layer.
PostgreSQL Database: Stores user data, OTPs, and application state
Twilio API: External service for sending SMS messages containing OTP codes and receiving delivery status updates
Prerequisites:
Node.js: Version 20.x or 22.x LTS recommended (as of January 2025). Node.js 18.x enters end-of-life in April 2025 and should not be used for new projects. npm or yarn installed.
Knowledge: Basic understanding of TypeScript, Node.js, REST APIs, and relational databases
Tools: Code editor (like VS Code) and API testing tool (like Postman or curl)
Security Best Practices:
Environment Variables: Never commit sensitive credentials (Twilio keys, JWT secrets, database passwords) to version control. Always use environment variables and add .env to your .gitignore file.
OTP Expiration: Implement short expiration times for OTPs (typically 5-10 minutes) to minimize the window of vulnerability.
Rate Limiting: Implement rate limiting on OTP generation and verification endpoints to prevent abuse and brute-force attacks.
HTTPS Only: Always use HTTPS in production to encrypt data in transit.
Final Outcome: A functional NestJS API with secure user authentication, phone verification, and optional SMS-based 2FA.
How Do You Set Up the NestJS Project?
Scaffold your NestJS project and configure the basic utilities.
Security Note: Before you begin, create a .gitignore file in your project root to protect sensitive information:
Bash
# Add to .gitignore
.env
.env.local
.env*.local
node_modules/
dist/
1.1 Install NestJS CLI:
Install the NestJS CLI globally if you haven't already.
Bash
npm install -g @nestjs/cli
1.2 Create New Project:
Generate a new NestJS project. Replace nestjs-twilio-2fa with your desired project name.
Bash
nest new nestjs-twilio-2fa
cd nestjs-twilio-2fa
This creates a standard NestJS project structure.
1.3 Initial Cleanup (Optional):
The default project includes sample controller and service files. Remove them for a clean start.
Delete src/app.controller.ts, src/app.service.ts, and src/app.controller.spec.ts.
Open src/app.module.ts and remove AppController from the controllers array and AppService from the providers array.
Global Prefix: Standardizes API access and enables versioning
Logger Middleware: Provides essential request visibility for debugging and monitoring
Custom Exception Filter: Ensures consistent, informative error responses for clients and detailed server-side logging, crucial for production environments
How Do You Configure Prisma with PostgreSQL?
Set up PostgreSQL and use Prisma to interact with it.
2.1 Set up PostgreSQL Database:
Create your database using psql or a GUI tool.
SQL
-- Connect using psql (replace 'postgres' with your superuser if different)-- psql -U postgresCREATE DATABASE nest_twilio_2fa_dev; -- Or your preferred name
2.2 Install Prisma:
Install the Prisma CLI as a development dependency and the Prisma Client.
Bash
npm install -D prisma
npm install @prisma/client
2.3 Initialize Prisma:
Create a prisma directory with a schema.prisma file and a .env file at the project root.
Bash
npx prisma init --datasource-provider postgresql
2.4 Configure Database Connection:
Prisma automatically creates a .env file. Update the DATABASE_URL with your PostgreSQL connection string.
Replace placeholders (YOUR_DB_USER, YOUR_DB_PASSWORD, etc.) with your actual database credentials.
Ensure schema=public is present if you're using the default schema.
Enclose the entire connection string in double quotes.
2.5 Define Data Models:
Open prisma/schema.prisma and define the User and Otp models.
prisma/schema.prisma:
Prisma
// Prisma schema file
// Learn more: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String @unique
phone String @unique // Ensure phone numbers are unique
password String // Hashed password
firstName String
lastName String
twoFA Boolean @default(false) // 2FA status
isPhoneVerified Boolean @default(false) // Phone verification status
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
otps Otp[] // Relation to Otp model
}
model Otp {
id String @id @default(uuid())
code String // The OTP code itself (6 digits recommended)
userId String // Foreign key to User
useCase UseCase // Purpose of the OTP
expiresAt DateTime // Expiry timestamp (recommended: 5-10 minutes from creation)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, useCase]) // Index for faster lookups
@@index([expiresAt]) // Index for cleanup queries
}
// Enum for different OTP use cases
enum UseCase {
PHONE_VERIFICATION // For verifying phone number initially
TWO_FACTOR_AUTH // For logging in when 2FA is enabled
DISABLE_TWO_FACTOR // Optional: For confirming disabling 2FA
}
Model Details:
User: Stores user details, including flags for twoFA and isPhoneVerified
Otp: Stores OTP codes linked to a user with a specific useCase and expiresAt timestamp. Best Practice: OTPs should expire within 5-10 minutes to minimize security risks.
UseCase Enum: Defines distinct purposes for OTP generation, improving clarity and logic
onDelete: Cascade: If a User is deleted, their associated OTPs are also deleted
@@index: Improves query performance when finding OTPs for a specific user and use case. Added index on expiresAt for efficient cleanup of expired OTPs.
2.6 Run Database Migration:
Apply the schema changes to your database. Prisma generates SQL migration files and runs them.
Bash
npx prisma migrate dev --name init_user_otp_schema
This command will:
Create a prisma/migrations folder
Generate a SQL migration file based on your schema changes
Apply the migration to your database, creating the User and Otp tables
Generate/update the Prisma Client based on your schema
Important: Whenever you change prisma/schema.prisma, run npx prisma migrate dev --name <descriptive_migration_name> to update your database and npx prisma generate to update the Prisma Client typings.
2.7 Create Prisma Service:
Encapsulate Prisma Client logic within a dedicated NestJS service for better organization and testability.
Generate the module and service:
Bash
nest generate module prisma
nest generate service prisma --no-spec # No spec file needed for this simple service
Implement the service (src/prisma/prisma.service.ts):
TypeScript
import { Injectable, OnModuleInit, INestApplication } from'@nestjs/common';
import { PrismaClient } from'@prisma/client';
@Injectable()
exportclassPrismaServiceextendsPrismaClientimplementsOnModuleInit {
asynconModuleInit() {
// Connect to the database when the module is initializedawaitthis.$connect();
}
asyncenableShutdownHooks(app: INestApplication) {
// Ensure Prisma disconnects gracefully on application shutdown
process.on('beforeExit', async () => {
console.log('Closing NestJS application and disconnecting Prisma Client...');
await app.close();
console.log('Prisma Client disconnected.');
});
}
}
Configure the module (src/prisma/prisma.module.ts):
TypeScript
import { Module, Global } from'@nestjs/common';
import { PrismaService } from'./prisma.service';
@Global() // Make PrismaService available globally@Module({
providers: [PrismaService],
exports: [PrismaService],
})
exportclassPrismaModule {}
Import PrismaModule into the root AppModule (src/app.module.ts):
Create a pipe to validate incoming request bodies using Joi schemas.
Create src/common/pipes/joi-validation.pipe.ts:
TypeScript
import {
PipeTransform,
Injectable,
ArgumentMetadata,
BadRequestException,
} from'@nestjs/common';
import { ObjectSchema } from'joi';
@Injectable()
exportclassJoiValidationPipeimplementsPipeTransform {
constructor(privateschema: ObjectSchema) {}
transform(value: any, metadata: ArgumentMetadata) {
// Only validate the body of the requestif (metadata.type !== 'body' || !value) {
return value;
}
const { error, value: validatedValue } = this.schema.validate(value, {
abortEarly: false, // Return all validation errorsallowUnknown: false, // Disallow properties not defined in the schemastripUnknown: true, // Remove unknown properties instead of throwing error
});
if (error) {
// Format error messages for better readabilityconst errorMessages = error.details.map((d) => d.message.replace(/"/g, "'")).join('; ');
thrownewBadRequestException(`Validation failed: ${errorMessages}`);
}
return validatedValue;
}
}
3.4 Create Sign-up DTO and Schema:
Define the expected request body structure.
Create src/account/dto/create-user.dto.ts:
TypeScript
// DTO helps with type inference in service/controllerexportclassCreateUserDto {
email: string;
phone: string;
password: string;
firstName: string;
lastName: string;
}
Create the Joi validation schema src/account/validation/signup.schema.ts:
TypeScript
import * asJoifrom'joi';
// Basic password complexity: at least 8 chars, 1 letter, 1 numberconst passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/;
// Basic phone number regex (adjust as needed for E.164 format)const phoneRegex = /^[+\d\s-]+$/;
exportconst signupSchema = Joi.object({
email: Joi.string().email().required().messages({
'string.email': 'Email must be a valid email address',
'any.required': 'Email is required',
}),
phone: Joi.string().pattern(phoneRegex).required().messages({
'string.pattern.base': 'Phone number format is invalid',
'any.required': 'Phone number is required',
}),
password: Joi.string().pattern(passwordRegex).required().messages({
'string.pattern.base': 'Password must be at least 8 characters long and include at least one letter and one number',
'any.required': 'Password is required',
}),
firstName: Joi.string().trim().min(1).required().messages({
'string.empty': 'First name cannot be empty',
'any.required': 'First name is required',
}),
lastName: Joi.string().trim().min(1).required().messages({
'string.empty': 'Last name cannot be empty',
'any.required': 'Last name is required',
}),
});
Joi Pipe: Provides robust, schema-based validation at the entry point of the controller
DTO: Enforces type safety within TypeScript code
bcrypt: Industry standard for password hashing
Conflict Check: Prevents duplicate user registrations based on unique fields with robust Prisma error handling
Password Omission: Never return the hashed password to the client
How Do You Implement JWT Authentication?
Set up the initial login mechanism using email and password, returning a JWT if credentials are valid but before handling the 2FA OTP step.
4.1 Install JWT Dependency:
Bash
npm install @nestjs/jwt dotenv
4.2 Configure JWT Module:
Define a JWT secret in your .env file. Use a strong, randomly generated secret in production.
.env:
.env
DATABASE_URL="postgresql://postgres:mysecretpassword@localhost:5432/nest_twilio_2fa_dev?schema=public"
# Generate a strong secret (e.g., using openssl rand -hex 32)
JWT_SECRET="your-very-strong-and-secret-jwt-key-CHANGE-ME"
JWT_EXPIRATION_TIME="2h" # Example: Token valid for 2 hours
# Twilio Configuration (add these when implementing Twilio integration)
TWILIO_ACCOUNT_SID="your_twilio_account_sid"
TWILIO_AUTH_TOKEN="your_twilio_auth_token"
TWILIO_PHONE_NUMBER="your_twilio_phone_number"
# OTP Configuration
OTP_EXPIRATION_MINUTES="10" # OTP validity period (recommended: 5-10 minutes)
OTP_LENGTH="6" # Standard 6-digit OTP
Create a configuration file for easy access to constants (src/common/config/config.ts):
TypeScript
import * as dotenv from'dotenv';
dotenv.config(); // Load .env file variablesexportconst config = {
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRATION_TIME || '1h',
},
twilio: {
accountSid: process.env.TWILIO_ACCOUNT_SID,
authToken: process.env.TWILIO_AUTH_TOKEN,
phoneNumber: process.env.TWILIO_PHONE_NUMBER,
},
otp: {
expirationMinutes: parseInt(process.env.OTP_EXPIRATION_MINUTES || '10', 10),
length: parseInt(process.env.OTP_LENGTH || '6', 10),
},
};
// Validate essential config on startupif (!config.jwt.secret || config.jwt.secret.length < 32) {
console.error("FATAL ERROR: JWT_SECRET environment variable is missing or too short (minimum 32 characters recommended).");
process.exit(1);
}
if (!config.twilio.accountSid || !config.twilio.authToken || !config.twilio.phoneNumber) {
console.warn("WARNING: Twilio credentials not configured. SMS functionality will not work.");
}
Related Resources
NestJS Security:
Authentication and Authorization in NestJS
Rate Limiting and Throttling with NestJS
Environment Variable Management Best Practices
Twilio Integration:
Twilio SMS API Documentation
Phone Number Verification Patterns
SMS Delivery Tracking and Error Handling
Database and ORM:
Prisma Schema Design Best Practices
PostgreSQL Performance Optimization
Database Migration Strategies with Prisma
Frequently asked questions
This guide details setting up 2FA using NestJS, Twilio, Prisma, and PostgreSQL. It covers user sign-up, login, phone verification, and 2FA-protected login. Key technologies include JWT for session management, bcrypt for password hashing, and Joi for validation.
NestJS is the core backend framework, providing structure, features, and TypeScript support for building the API. It's chosen for its modularity and efficiency in handling requests and coordinating with services like Twilio and Prisma.
Twilio is a reliable cloud communication platform. It's used to send OTPs via SMS for phone verification and two-factor authentication, adding an extra security layer to user logins.
Logging middleware is crucial for debugging and monitoring, providing context for each request. It's recommended to set this up early in the project for visibility into HTTP requests and responses, helping track issues in development and production.
Create a custom exception filter that implements NestJS's ExceptionFilter interface. This allows you to catch and handle errors gracefully, providing consistent JSON error responses to clients, and logging errors with stack traces on the server-side, which is important for debugging and maintenance.
Install the Prisma CLI globally using npm install -D prisma and the client library with npm install @prisma/client. Initialize Prisma with npx prisma init --datasource-provider postgresql. This sets up the necessary files for defining your data models.
Prisma is a modern database toolkit that simplifies database access with type safety and migrations in Node.js and TypeScript. It's used as the Object-Relational Mapper (ORM) to interact with the PostgreSQL database, managing connections and queries.
Generate a module and service using the NestJS CLI: nest generate module prisma and nest generate service prisma. In the service, extend the PrismaClient and implement onModuleInit to establish a database connection when the module initializes.
Creating a Prisma service follows SOLID principles and improves code organization and testability. It centralizes database logic, making it easier to manage database interactions within your NestJS application.
The project uses PostgreSQL, chosen for its reliability and robustness. The Prisma schema defines User and OTP models, which are migrated to the database.
Joi is a data validation library. You create a schema and use it with a custom pipe in your NestJS controller. This validates incoming requests, ensuring they adhere to the schema, and provides specific error messages for improved DX.
Hashing protects sensitive data even if the database is compromised. This guide uses bcrypt with a recommended salt round of 10. Never store passwords in plain text.
Generate an account module, controller, and service. Implement the signup logic in the service and define the POST route handler in the controller. Use a validation pipe (e.g., Joi) for data integrity.
Before creating a new user, query the database to check if a user with the given email or phone number already exists. If a duplicate is found, throw a ConflictException to inform the client.
JWT (JSON Web Token) is used for stateless session management. After successful password verification, the server generates and returns a JWT, which the client uses for subsequent authenticated requests.
Build secure SMS-based Two-Factor Authentication (2FA) in NestJS with Twilio. This comprehensive tutorial covers OTP phone verification, JWT authentication, Prisma ORM with PostgreSQL, bcrypt password hashing, and production-ready security practices including rate limiting and environment variable protection.
Last Updated: January 2025
What You'll Build
Build a secure NestJS backend application featuring:
Problem Solved: Add a critical security layer beyond passwords, mitigating risks from compromised credentials by requiring access to the user's registered phone.
Technologies Used:
System Architecture:
Your system comprises these interacting components:
Prerequisites:
Security Best Practices:
.envto your.gitignorefile.Final Outcome: A functional NestJS API with secure user authentication, phone verification, and optional SMS-based 2FA.
How Do You Set Up the NestJS Project?
Scaffold your NestJS project and configure the basic utilities.
Security Note: Before you begin, create a
.gitignorefile in your project root to protect sensitive information:# Add to .gitignore .env .env.local .env*.local node_modules/ dist/1.1 Install NestJS CLI:
Install the NestJS CLI globally if you haven't already.
1.2 Create New Project:
Generate a new NestJS project. Replace
nestjs-twilio-2fawith your desired project name.nest new nestjs-twilio-2fa cd nestjs-twilio-2faThis creates a standard NestJS project structure.
1.3 Initial Cleanup (Optional):
The default project includes sample controller and service files. Remove them for a clean start.
src/app.controller.ts,src/app.service.ts, andsrc/app.controller.spec.ts.src/app.module.tsand removeAppControllerfrom thecontrollersarray andAppServicefrom theprovidersarray.src/app.module.ts(after cleanup):import { Module } from '@nestjs/common'; @Module({ imports: [], controllers: [], // Remove AppController providers: [], // Remove AppService }) export class AppModule {}1.4 Set Global API Prefix:
Prefix your API routes (e.g.,
/api/v1) for better organization.Edit
src/main.ts:import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); // Set global prefix app.setGlobalPrefix('api/v1'); await app.listen(process.env.PORT || 3000); // Use environment variable for port console.log(`Application is running on: ${await app.getUrl()}`); } bootstrap();1.5 Configure Logging Middleware:
NestJS has built-in logging, but a custom middleware provides more request context.
Create
src/common/middleware/logger.middleware.ts:import { Injectable, NestMiddleware, Logger } from '@nestjs/common'; import { Request, Response, NextFunction } from 'express'; @Injectable() export class LoggerMiddleware implements NestMiddleware { private logger = new Logger('HTTP'); // Context for logger use(request: Request, response: Response, next: NextFunction): void { const { ip, method, originalUrl } = request; const userAgent = request.get('user-agent') || ''; response.on('finish', () => { const { statusCode } = response; const contentLength = response.get('content-length'); this.logger.log( `${method} ${originalUrl} ${statusCode} ${contentLength || 0} - ${userAgent} ${ip}`, ); }); next(); } }Register the middleware globally in
src/app.module.ts:import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common'; import { LoggerMiddleware } from './common/middleware/logger.middleware'; // Adjust path if needed @Module({ imports: [], controllers: [], providers: [], }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer.apply(LoggerMiddleware).forRoutes('*'); // Apply to all routes } }1.6 Custom Exception Filter:
Handle errors gracefully and provide consistent error responses.
Create the custom filter (
src/common/filters/custom-exception.filter.ts):import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger, } from '@nestjs/common'; import { Request, Response } from 'express'; @Catch() // Catch all types of exceptions export class CustomExceptionFilter implements ExceptionFilter { private readonly logger = new Logger(CustomExceptionFilter.name); catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse<Response>(); const request = ctx.getRequest<Request>(); let status: number; let message: string; let errorName: string; let stack: string | undefined; if (exception instanceof HttpException) { status = exception.getStatus(); const errorResponse = exception.getResponse(); message = typeof errorResponse === 'string' ? errorResponse : (errorResponse as any)?.message || exception.message; errorName = exception.constructor.name; } else if (exception instanceof Error) { // Handle generic errors status = (exception as any)?.statusCode || HttpStatus.INTERNAL_SERVER_ERROR; message = exception.message || 'Internal server error'; errorName = exception.name || 'InternalServerError'; stack = exception.stack; } else { // Handle non-Error exceptions status = HttpStatus.INTERNAL_SERVER_ERROR; message = 'An unexpected error occurred'; errorName = 'UnknownError'; try { stack = JSON.stringify(exception); } catch { stack = 'Could not stringify the unknown exception.'; } } // Log the error this.logger.error( `HTTP Status: ${status} Error: ${errorName} Message: ${message} Path: ${request.url}`, stack, CustomExceptionFilter.name, ); response.status(status).json({ statusCode: status, timestamp: new Date().toISOString(), path: request.url, error: errorName, message: message, }); } }Register the filter globally in
src/main.ts:import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { CustomExceptionFilter } from './common/filters/custom-exception.filter'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.setGlobalPrefix('api/v1'); app.useGlobalFilters(new CustomExceptionFilter()); // Register filter await app.listen(process.env.PORT || 3000); console.log(`Application is running on: ${await app.getUrl()}`); } bootstrap();Why these choices?
How Do You Configure Prisma with PostgreSQL?
Set up PostgreSQL and use Prisma to interact with it.
2.1 Set up PostgreSQL Database:
Create your database using
psqlor a GUI tool.-- Connect using psql (replace 'postgres' with your superuser if different) -- psql -U postgres CREATE DATABASE nest_twilio_2fa_dev; -- Or your preferred name2.2 Install Prisma:
Install the Prisma CLI as a development dependency and the Prisma Client.
2.3 Initialize Prisma:
Create a
prismadirectory with aschema.prismafile and a.envfile at the project root.2.4 Configure Database Connection:
Prisma automatically creates a
.envfile. Update theDATABASE_URLwith your PostgreSQL connection string..env:YOUR_DB_USER,YOUR_DB_PASSWORD, etc.) with your actual database credentials.schema=publicis present if you're using the default schema.2.5 Define Data Models:
Open
prisma/schema.prismaand define theUserandOtpmodels.prisma/schema.prisma:Model Details:
twoFAandisPhoneVerifieduseCaseandexpiresAttimestamp. Best Practice: OTPs should expire within 5-10 minutes to minimize security risks.expiresAtfor efficient cleanup of expired OTPs.2.6 Run Database Migration:
Apply the schema changes to your database. Prisma generates SQL migration files and runs them.
This command will:
prisma/migrationsfolderUserandOtptablesImportant: Whenever you change
prisma/schema.prisma, runnpx prisma migrate dev --name <descriptive_migration_name>to update your database andnpx prisma generateto update the Prisma Client typings.2.7 Create Prisma Service:
Encapsulate Prisma Client logic within a dedicated NestJS service for better organization and testability.
Generate the module and service:
nest generate module prisma nest generate service prisma --no-spec # No spec file needed for this simple serviceImplement the service (
src/prisma/prisma.service.ts):import { Injectable, OnModuleInit, INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit { async onModuleInit() { // Connect to the database when the module is initialized await this.$connect(); } async enableShutdownHooks(app: INestApplication) { // Ensure Prisma disconnects gracefully on application shutdown process.on('beforeExit', async () => { console.log('Closing NestJS application and disconnecting Prisma Client...'); await app.close(); console.log('Prisma Client disconnected.'); }); } }Configure the module (
src/prisma/prisma.module.ts):import { Module, Global } from '@nestjs/common'; import { PrismaService } from './prisma.service'; @Global() // Make PrismaService available globally @Module({ providers: [PrismaService], exports: [PrismaService], }) export class PrismaModule {}Import
PrismaModuleinto the rootAppModule(src/app.module.ts):import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common'; import { LoggerMiddleware } from './common/middleware/logger.middleware'; import { PrismaModule } from './prisma/prisma.module'; @Module({ imports: [PrismaModule], controllers: [], providers: [], }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer.apply(LoggerMiddleware).forRoutes('*'); } }Enable shutdown hooks in
src/main.ts:import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { CustomExceptionFilter } from './common/filters/custom-exception.filter'; import { PrismaService } from './prisma/prisma.service'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.setGlobalPrefix('api/v1'); app.useGlobalFilters(new CustomExceptionFilter()); // Get PrismaService instance and enable shutdown hooks const prismaService = app.get(PrismaService); await prismaService.enableShutdownHooks(app); await app.listen(process.env.PORT || 3000); console.log(`Application is running on: ${await app.getUrl()}`); } bootstrap();Why these choices?
PrismaServiceeasily injectable across the applicationHow Do You Implement User Registration?
Create the endpoints and logic for user registration.
3.1 Install Dependencies:
3.2 Create Account Module, Controller, Service:
3.3 Create Validation Pipe:
Create a pipe to validate incoming request bodies using Joi schemas.
Create
src/common/pipes/joi-validation.pipe.ts:import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException, } from '@nestjs/common'; import { ObjectSchema } from 'joi'; @Injectable() export class JoiValidationPipe implements PipeTransform { constructor(private schema: ObjectSchema) {} transform(value: any, metadata: ArgumentMetadata) { // Only validate the body of the request if (metadata.type !== 'body' || !value) { return value; } const { error, value: validatedValue } = this.schema.validate(value, { abortEarly: false, // Return all validation errors allowUnknown: false, // Disallow properties not defined in the schema stripUnknown: true, // Remove unknown properties instead of throwing error }); if (error) { // Format error messages for better readability const errorMessages = error.details.map((d) => d.message.replace(/"/g, "'")).join('; '); throw new BadRequestException(`Validation failed: ${errorMessages}`); } return validatedValue; } }3.4 Create Sign-up DTO and Schema:
Define the expected request body structure.
Create
src/account/dto/create-user.dto.ts:// DTO helps with type inference in service/controller export class CreateUserDto { email: string; phone: string; password: string; firstName: string; lastName: string; }Create the Joi validation schema
src/account/validation/signup.schema.ts:import * as Joi from 'joi'; // Basic password complexity: at least 8 chars, 1 letter, 1 number const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/; // Basic phone number regex (adjust as needed for E.164 format) const phoneRegex = /^[+\d\s-]+$/; export const signupSchema = Joi.object({ email: Joi.string().email().required().messages({ 'string.email': 'Email must be a valid email address', 'any.required': 'Email is required', }), phone: Joi.string().pattern(phoneRegex).required().messages({ 'string.pattern.base': 'Phone number format is invalid', 'any.required': 'Phone number is required', }), password: Joi.string().pattern(passwordRegex).required().messages({ 'string.pattern.base': 'Password must be at least 8 characters long and include at least one letter and one number', 'any.required': 'Password is required', }), firstName: Joi.string().trim().min(1).required().messages({ 'string.empty': 'First name cannot be empty', 'any.required': 'First name is required', }), lastName: Joi.string().trim().min(1).required().messages({ 'string.empty': 'Last name cannot be empty', 'any.required': 'Last name is required', }), });3.5 Create Password Hashing Utility:
Create
src/common/utils/password.util.ts:import * as bcrypt from 'bcrypt'; // OWASP recommends 10-12 rounds as of 2025 // Source: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html const SALT_ROUNDS = 10; export const hashPassword = async (password: string): Promise<string> => { return bcrypt.hash(password, SALT_ROUNDS); }; export const verifyPassword = async ( password: string, hash: string, ): Promise<boolean> => { return bcrypt.compare(password, hash); };3.6 Implement Sign-up Service Logic:
Inject
PrismaServiceand implement the sign-up logic.Edit
src/account/account.service.ts:import { Injectable, ConflictException, InternalServerErrorException, Logger } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateUserDto } from './dto/create-user.dto'; import { hashPassword } from '../common/utils/password.util'; import { User } from '@prisma/client'; import { Prisma } from '@prisma/client'; @Injectable() export class AccountService { private readonly logger = new Logger(AccountService.name); constructor(private prisma: PrismaService) {} async signUp(createUserDto: CreateUserDto): Promise<Omit<User, 'password'>> { const { email, phone, password, firstName, lastName } = createUserDto; // 1. Check if user with email or phone already exists const existingUser = await this.prisma.user.findFirst({ where: { OR: [{ email }, { phone }] }, select: { email: true, phone: true } }); if (existingUser) { if (existingUser.email === email) { this.logger.warn(`Signup attempt failed: Email ${email} already exists.`); throw new ConflictException('User with this email already exists'); } if (existingUser.phone === phone) { this.logger.warn(`Signup attempt failed: Phone ${phone} already exists.`); throw new ConflictException('User with this phone number already exists'); } } // 2. Hash the password const hashedPassword = await hashPassword(password); // 3. Create the user try { const user = await this.prisma.user.create({ data: { email, phone, password: hashedPassword, firstName, lastName, }, }); this.logger.log(`User created successfully: ${user.id} / ${user.email}`); // 4. Omit password from the returned user object const { password: _, ...result } = user; return result; } catch (error) { // Catch unique constraint violations (race condition safeguard) if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { const target = (error.meta as any)?.target; this.logger.warn(`Signup failed due to unique constraint violation on: ${target}`); if (target && target.includes('email')) { throw new ConflictException('User with this email already exists.'); } else if (target && target.includes('phone')) { throw new ConflictException('User with this phone number already exists.'); } else { throw new ConflictException('A user with these details already exists.'); } } // Log and re-throw other errors this.logger.error(`Error during user signup: ${error.message}`, error.stack); throw new InternalServerErrorException('Could not create user account.'); } } // Additional account service methods will go here }3.7 Implement Sign-up Controller Endpoint:
Define the route handler in the controller.
Edit
src/account/account.controller.ts:import { Controller, Post, Body, UsePipes, HttpStatus, HttpCode } from '@nestjs/common'; import { AccountService } from './account.service'; import { CreateUserDto } from './dto/create-user.dto'; import { signupSchema } from './validation/signup.schema'; import { JoiValidationPipe } from '../common/pipes/joi-validation.pipe'; import { User } from '@prisma/client'; @Controller('account') // Route prefix: /api/v1/account export class AccountController { constructor(private readonly accountService: AccountService) {} @Post('signup') // POST /api/v1/account/signup @UsePipes(new JoiValidationPipe(signupSchema)) // Apply Joi validation @HttpCode(HttpStatus.CREATED) // Set default success status to 201 async create( @Body() createUserDto: CreateUserDto, ): Promise<Omit<User, 'password'>> { return this.accountService.signUp(createUserDto); } // Additional account controller methods }Ensure the
AccountServiceandAccountControllerare correctly listed insrc/account/account.module.ts.src/account/account.module.ts:import { Module } from '@nestjs/common'; import { AccountService } from './account.service'; import { AccountController } from './account.controller'; @Module({ controllers: [AccountController], providers: [AccountService], exports: [AccountService] // Export if needed by other modules like AuthModule }) export class AccountModule {}Import
AccountModuleinto the rootAppModule(src/app.module.ts):import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common'; import { LoggerMiddleware } from './common/middleware/logger.middleware'; import { PrismaModule } from './prisma/prisma.module'; import { AccountModule } from './account/account.module'; @Module({ imports: [ PrismaModule, AccountModule ], controllers: [], providers: [], }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { consumer.apply(LoggerMiddleware).forRoutes('*'); } }Why these choices?
How Do You Implement JWT Authentication?
Set up the initial login mechanism using email and password, returning a JWT if credentials are valid but before handling the 2FA OTP step.
4.1 Install JWT Dependency:
4.2 Configure JWT Module:
Define a JWT secret in your
.envfile. Use a strong, randomly generated secret in production..env:Create a configuration file for easy access to constants (
src/common/config/config.ts):import * as dotenv from 'dotenv'; dotenv.config(); // Load .env file variables export const config = { jwt: { secret: process.env.JWT_SECRET, expiresIn: process.env.JWT_EXPIRATION_TIME || '1h', }, twilio: { accountSid: process.env.TWILIO_ACCOUNT_SID, authToken: process.env.TWILIO_AUTH_TOKEN, phoneNumber: process.env.TWILIO_PHONE_NUMBER, }, otp: { expirationMinutes: parseInt(process.env.OTP_EXPIRATION_MINUTES || '10', 10), length: parseInt(process.env.OTP_LENGTH || '6', 10), }, }; // Validate essential config on startup if (!config.jwt.secret || config.jwt.secret.length < 32) { console.error("FATAL ERROR: JWT_SECRET environment variable is missing or too short (minimum 32 characters recommended)."); process.exit(1); } if (!config.twilio.accountSid || !config.twilio.authToken || !config.twilio.phoneNumber) { console.warn("WARNING: Twilio credentials not configured. SMS functionality will not work."); }Related Resources
NestJS Security:
Twilio Integration:
Database and ORM: