code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

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:

  1. User Sign-up: Register users with email, phone number, and password
  2. User Login: Authenticate with email/password and receive a JSON Web Token (JWT)
  3. Phone Number Verification: Verify phone numbers via SMS OTP before enabling 2FA
  4. 2FA Enable/Disable: Secure endpoints for users to opt-in or opt-out of 2FA with OTP confirmation
  5. 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:

  1. Client (Browser/App): Interacts with the NestJS API
  2. NestJS API: Handles incoming requests, business logic, validation, authentication, and coordinates with Prisma and Twilio. Contains Controllers, Services, Guards, and Pipes.
  3. Prisma ORM: Manages database connections, queries, and migrations based on the defined schema. Acts as the data access layer.
  4. PostgreSQL Database: Stores user data, OTPs, and application state
  5. 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 .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.

src/app.module.ts (after cleanup):

typescript
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:

typescript
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:

typescript
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:

typescript
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):

typescript
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:

typescript
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.

sql
-- Connect using psql (replace 'postgres' with your superuser if different)
-- psql -U postgres

CREATE 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.

.env:

dotenv
# 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=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:

  1. Create a prisma/migrations folder
  2. Generate a SQL migration file based on your schema changes
  3. Apply the migration to your database, creating the User and Otp tables
  4. 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()
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):

typescript
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):

typescript
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:

typescript
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 PrismaService easily 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:

bash
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:

bash
nest generate module account
nest generate controller account --no-spec
nest generate service account --no-spec

3.3 Create Validation Pipe:

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()
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:

typescript
// 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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):

typescript
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:

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:

dotenv
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 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.");
}

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.