code examples

Sent logo
Sent TeamMar 8, 2026 / 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