code examples
code examples
NestJS SMS 2FA with Twilio: Complete OTP Authentication Tutorial
Build secure two-factor authentication in NestJS using Twilio SMS API. Learn phone verification, OTP generation, JWT authentication, Prisma PostgreSQL integration, and production-ready security practices.
NestJS SMS 2FA with Twilio: Complete OTP Authentication Tutorial
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.
- Node.js: JavaScript runtime environment.
- TypeScript: JavaScript superset adding static types, improving code quality and maintainability.
- Twilio: Cloud communications platform for sending SMS OTPs. Provides reliable API and developer-friendly tools. (Twilio SDK supports Node.js 14, 16, 18, 20, and 22 LTS, verified January 2025)
- 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.
- PostgreSQL: Server installed and running
- Twilio Account: With credentials (Account SID, Auth Token, Twilio Phone Number)
- 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
.envto your.gitignorefile. - 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:
# 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.
npm install -g @nestjs/cli1.2 Create New Project:
Generate a new NestJS project. Replace nestjs-twilio-2fa with 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.
- Delete
src/app.controller.ts,src/app.service.ts, andsrc/app.controller.spec.ts. - Open
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?
- 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.
-- 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.
npm install -D prisma
npm install @prisma/client2.3 Initialize Prisma:
Create a prisma directory with a schema.prisma file and a .env file at the project root.
npx prisma init --datasource-provider postgresql2.4 Configure Database Connection:
Prisma automatically creates a .env file. Update the DATABASE_URL with your PostgreSQL connection string.
.env:
# Prisma Database Connection
DATABASE_URL="postgresql://YOUR_DB_USER:YOUR_DB_PASSWORD@YOUR_DB_HOST:YOUR_DB_PORT/YOUR_DB_NAME?schema=public"
# Example:
# DATABASE_URL="postgresql://postgres:mysecretpassword@localhost:5432/nest_twilio_2fa_dev?schema=public"- Replace placeholders (
YOUR_DB_USER,YOUR_DB_PASSWORD, etc.) with your actual database credentials. - Ensure
schema=publicis 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 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
twoFAandisPhoneVerified - Otp: Stores OTP codes linked to a user with a specific
useCaseandexpiresAttimestamp. 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
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.
npx prisma migrate dev --name init_user_otp_schemaThis command will:
- Create a
prisma/migrationsfolder - Generate a SQL migration file based on your schema changes
- Apply the migration to your database, creating the
UserandOtptables - 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:
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 PrismaModule into the root AppModule (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?
- Prisma: Provides type safety, auto-completion, and simplifies database interactions and migrations significantly
- Prisma Service: Decouples database logic from business logic, adhering to SOLID principles
- Global Module: Makes the
PrismaServiceeasily injectable across the application - Shutdown Hooks: Ensures the database connection closes properly when the application stops
- UUIDs: Better for distributed systems and avoids exposing sequential information compared to auto-increment integers
How Do You Implement User Registration?
Create the endpoints and logic for user registration.
3.1 Install Dependencies:
npm install joi bcrypt class-validator class-transformer
npm install -D @types/bcrypt @types/joi- joi: Request validation schemas used with our custom pipe
- bcrypt: Secure password hashing
- class-validator, class-transformer: Standard NestJS validation libraries
3.2 Create Account Module, Controller, Service:
nest generate module account
nest generate controller account --no-spec
nest generate service account --no-spec3.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 PrismaService and 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 AccountService and AccountController are correctly listed in src/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 AccountModule into the root AppModule (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?
- 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:
npm install @nestjs/jwt dotenv4.2 Configure JWT Module:
Define a JWT secret in your .env file. Use a strong, randomly generated secret in production.
.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 OTPCreate 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:
- 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
How to set up two-factor authentication with NestJS?
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.
What is NestJS used for in this project?
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.
Why use Twilio for SMS authentication?
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.
When should I implement logging middleware in NestJS?
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.
How to handle errors in NestJS with a custom exception filter?
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.
How to install Prisma for database interactions?
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.
What is Prisma used for in the NestJS project?
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.
How to create a Prisma service in NestJS?
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.
Why is a Prisma service recommended?
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.
What database is used and why?
The project uses PostgreSQL, chosen for its reliability and robustness. The Prisma schema defines User and OTP models, which are migrated to the database.
What is Joi and how to use it for validation?
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.
Why hash passwords and how is it done?
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.
How to create a user signup endpoint in NestJS?
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.
How to prevent duplicate users during signup?
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.
What is JWT used for in user login?
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.