code examples

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

NestJS SMS OTP Authentication: Build Secure 2FA with Plivo (2024 Guide)

Step-by-step tutorial for implementing SMS OTP authentication in NestJS using Plivo. Includes NIST-compliant 2FA, rate limiting, TypeScript examples, and production security best practices for Node.js applications.

Implement Production-Ready SMS OTP/2FA in NestJS with Plivo

Learn how to implement SMS-based One-Time Password (OTP) authentication in NestJS using Plivo's messaging API. This comprehensive guide walks you through building a production-ready two-factor authentication (2FA) system with TypeScript, complete with security best practices, NIST SP 800-63B compliance, rate limiting, and error handling. You'll create secure API endpoints for generating and verifying OTP codes via SMS, protecting user accounts with an additional authentication layer beyond passwords.

This NestJS OTP authentication implementation significantly enhances application security by adding a second verification factor. Even if passwords are compromised, attackers cannot access accounts without the SMS-delivered one-time code.

⚠️ Security Notice: This guide uses speakeasy for OTP generation. As of 2024, the speakeasy library is marked as "NOT MAINTAINED" on npm and GitHub. For production applications, consider using actively maintained alternatives like otplib (https://www.npmjs.com/package/otplib) which provides similar TOTP/HOTP functionality with continued security updates. Source: Speakeasy GitHub Repository (2024)

NIST Compliance: SMS-based OTP is acceptable for NIST Authentication Assurance Levels 1 and 2 (AAL1/AAL2) but is not considered phishing-resistant. NIST SP 800-63B permits SMS OTPs when delivered over separate communication channels, though stronger alternatives (FIDO2, app-based authenticators) are recommended for higher security requirements. Source: NIST SP 800-63B Digital Identity Guidelines (2024)

What You'll Learn

This tutorial covers:

  • Setting up a NestJS project with Plivo SMS integration
  • Implementing secure OTP generation and verification endpoints
  • NIST-compliant security best practices for SMS authentication
  • Rate limiting and brute-force attack prevention
  • Production-ready error handling and logging strategies

Technologies Used

  • NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Its modular architecture and built-in features (like dependency injection, validation pipes) make it ideal for this task. Current version: 11.x (as of October 2024).
  • Node.js: The underlying JavaScript runtime environment. Recommended: Node.js 22 LTS (entered Long Term Support October 29, 2024, supported until April 2027). Source: Node.js v22 LTS Announcement (October 2024)
  • Plivo: A cloud communications platform providing APIs for SMS, Voice, and more. You'll use their Node.js helper library (latest: plivo npm package, actively maintained) to send OTP messages reliably.
  • TypeScript: Provides static typing for cleaner, more maintainable code.
  • dotenv / @nestjs/config: For managing environment variables securely.
  • class-validator / class-transformer: For robust request data validation.
  • bcrypt / @types/bcrypt: For hashing OTPs if storing persistently (conceptual example).
  • libphonenumber-js: For robust phone number parsing and validation (optional).
  • async-retry / @types/async-retry: For implementing retry logic on API calls (optional).
  • (Optional) @nestjs/throttler: For rate limiting API requests.
  • (Optional) speakeasy: A library for generating time-based or counter-based OTPs. ⚠️ Warning: Marked as NOT MAINTAINED. Consider using otplib instead for production.

How SMS OTP Authentication Works

The basic flow for OTP verification involves these steps:

text
+-----------+       1. Request OTP        +----------------+       2. Generate & Store OTP        +-------------+
|  Client   | ------------------------> | NestJS Backend | -----------------------------------> | OTP Storage |
| (Web/App) |       (Phone Number)      |   (Auth API)   |       (In-Memory/DB, w/ Expiry)    |  (Map/Cache)  |
+-----------+                           +----------------+ <---------------------------------- +-------------+
      ^                                         | 3. Send OTP via Plivo
      |                                         |
      | 6. Verify OTP Request                   v
      | (Phone Number, OTP)                 +-------+         4. Deliver SMS         +-------------+
      +------------------------------------ | Plivo | ----------------------------> | User's Phone|
                                            +-------+                               +-------------+
                                                                                          | 5. User Enters OTP
                                                                                          |
                                                                                          v
                                                                                    +-----------+
                                                                                    |  Client   |
                                                                                    +-----------+

What You'll Build

By the end of this guide, you will have:

  • A dedicated NestJS module (AuthModule) responsible for OTP logic.
  • API endpoints:
    • POST /auth/send-otp: Accepts a phone number, generates an OTP, stores it temporarily, and sends it via Plivo SMS.
    • POST /auth/verify-otp: Accepts a phone number and the user-provided OTP, validating it against the stored value and expiry.
  • Secure handling of Plivo credentials and OTP secrets.
  • Basic rate limiting and error handling.

Prerequisites

  • Node.js (LTS version recommended) and npm/yarn installed. Install Node.js
  • A Plivo account. Sign up for Plivo
  • Basic understanding of TypeScript and NestJS fundamentals (modules, controllers, services).
  • A code editor (like VS Code).
  • A tool for making API requests (like curl or Postman).

SMS OTP Security Best Practices

  • OTP Expiry: NIST SP 800-63B recommends OTP codes expire within 10 minutes or less. The default configuration in this guide uses 5 minutes (300 seconds), which provides a good balance between security and user experience. Source: NIST SP 800-63B (2024)
  • Rate Limiting: Implement aggressive rate limiting on OTP endpoints. This guide configures 3 requests per minute for send-otp and 5 for verify-otp. Adjust based on your threat model.
  • Storage: ⚠️ Production Warning: In-memory storage is suitable for development only. For production, use Redis or Memcached via @nestjs/cache-manager with TTL-based expiration. Database storage requires manual cleanup jobs.
  • Secret Strength: OTP secrets must provide at least 112 bits of security as specified in NIST SP 800-131A. Use cryptographically secure random string generators (e.g., crypto.randomBytes(32).toString('base32')). Source: NIST SP 800-63B (2024)
  • Attempt Limiting: This guide implements 3 maximum verification attempts per OTP to prevent brute-force attacks. After 3 failed attempts, users must request a new OTP.

1. Setting Up Your NestJS Project

Let's initialize our NestJS project and install the necessary dependencies.

  1. Install NestJS CLI: If you don't have it, install it globally.

    bash
    npm install -g @nestjs/cli
  2. Create New NestJS Project:

    bash
    nest new plivo-otp-app

    Choose your preferred package manager (npm or yarn).

  3. Navigate to Project Directory:

    bash
    cd plivo-otp-app
  4. Install Dependencies:

    bash
    npm install plivo @nestjs/config class-validator class-transformer speakeasy @types/speakeasy @nestjs/throttler bcrypt @types/bcrypt libphonenumber-js async-retry @types/async-retry
    # or using yarn:
    # yarn add plivo @nestjs/config class-validator class-transformer speakeasy @types/speakeasy @nestjs/throttler bcrypt @types/bcrypt libphonenumber-js async-retry @types/async-retry
    
    # Alternative: For production applications, consider using otplib instead of speakeasy:
    # npm install otplib @types/otplib
    • plivo: The official Plivo Node.js SDK.
    • @nestjs/config: For managing environment variables.
    • class-validator, class-transformer: For validating incoming request bodies (DTOs).
    • speakeasy: A library for generating OTP codes. ⚠️ Note: Marked as NOT MAINTAINED as of 2024. This guide uses it for educational purposes, but consider otplib for production. Source: Speakeasy GitHub (2024)
    • @types/speakeasy: TypeScript definitions for speakeasy.
    • @nestjs/throttler: For implementing rate limiting (optional but recommended).
    • bcrypt, @types/bcrypt: For hashing secrets (used conceptually in DB section).
    • libphonenumber-js: For robust phone number handling (optional).
    • async-retry, @types/async-retry: For API call retry logic (optional).
  5. Project Structure: NestJS CLI creates a standard structure. We'll primarily work within the src directory, creating modules, controllers, and services.

  6. Environment Variables (.env): Create a .env file in the project root. Never commit this file to version control. Add your Plivo credentials and configuration secrets:

    dotenv
    # .env
    
    # Plivo Credentials (Get from Plivo Console > API Keys & Credentials)
    PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID
    PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN
    
    # Plivo Sender ID (A Plivo phone number you've rented, capable of sending SMS)
    PLIVO_SENDER_ID=+15551234567 # Use E.164 format
    
    # OTP Configuration
    OTP_SECRET=YOUR_SUPER_SECRET_RANDOM_STRING_FOR_HMAC # Use a strong random string
    OTP_EXPIRY_SECONDS=300 # e.g., 5 minutes
    OTP_DIGITS=6 # Number of digits for the OTP code
    
    # Application Port
    PORT=3000
    
    # Rate Limiter (Optional)
    THROTTLE_TTL=60 # Time window in seconds
    THROTTLE_LIMIT=10 # Max requests per window
    • PLIVO_AUTH_ID / PLIVO_AUTH_TOKEN: Your unique Plivo API credentials. Find these in your Plivo Console under "API Keys & Credentials".
    • PLIVO_SENDER_ID: A Plivo phone number you have rented that is enabled for sending SMS messages. Ensure it's in E.164 format (e.g., +14155552671). You can rent numbers in the Plivo console under "Phone Numbers".
    • OTP_SECRET: A strong, unique secret string used by speakeasy for HMAC-based OTP generation (HOTP). Generate a secure random string for this.
    • OTP_EXPIRY_SECONDS: How long an OTP remains valid after generation.
    • OTP_DIGITS: The length of the OTP code sent to the user.
    • PORT: The port your NestJS application will run on.
    • THROTTLE_TTL / THROTTLE_LIMIT: Configuration for the rate limiter.
  7. Configure ConfigModule: Import and configure the ConfigModule in your main application module (src/app.module.ts) to load the .env file. Make it global so you don't need to import it into every module.

    typescript
    // src/app.module.ts
    import { Module } from '@nestjs/common';
    import { AppController } from './app.controller';
    import { AppService } from './app.service';
    import { ConfigModule } from '@nestjs/config';
    import { AuthModule } from './auth/auth.module'; // We will create this next
    import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
    import { APP_GUARD } from '@nestjs/core';
    
    @Module({
      imports: [
        ConfigModule.forRoot({
          isGlobal: true, // Make ConfigService available globally
          envFilePath: '.env',
        }),
        ThrottlerModule.forRoot([{
          ttl: parseInt(process.env.THROTTLE_TTL || '60', 10) * 1000, // Convert seconds to milliseconds
          limit: parseInt(process.env.THROTTLE_LIMIT || '10', 10),
        }]),
        AuthModule, // Import our feature module
      ],
      controllers: [AppController],
      providers: [
        AppService,
        {
          provide: APP_GUARD, // Apply rate limiting globally
          useClass: ThrottlerGuard,
        },
      ],
    })
    export class AppModule {}
    • ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }): Loads variables from .env and makes ConfigService injectable anywhere.
    • ThrottlerModule.forRoot([...]): Configures the rate limiter using environment variables. We convert TTL to milliseconds as required by the module.
    • APP_GUARD: Applies the ThrottlerGuard to all routes in the application.
  8. Enable Validation Pipe: Globally enable the ValidationPipe in src/main.ts to automatically validate incoming DTOs based on class-validator decorators.

    typescript
    // src/main.ts
    import { NestFactory } from '@nestjs/core';
    import { AppModule } from './app.module';
    import { ValidationPipe } from '@nestjs/common';
    import { ConfigService } from '@nestjs/config';
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
      const configService = app.get(ConfigService); // Get config service instance
    
      // Enable global validation pipe
      app.useGlobalPipes(new ValidationPipe({
        whitelist: true, // Strip properties not defined in DTO
        transform: true, // Automatically transform payloads to DTO instances
        transformOptions: {
          enableImplicitConversion: true, // Allow basic type conversions
        },
      }));
    
      const port = configService.get<number>('PORT') || 3000; // Use env var for port
      await app.listen(port);
      console.log(`Application is running on: ${await app.getUrl()}`);
    }
    bootstrap();

2. Implementing OTP Generation and Verification

We'll encapsulate the OTP logic within an AuthModule.

  1. Generate Auth Module, Controller, and Service: Use the NestJS CLI:

    bash
    nest generate module auth
    nest generate controller auth --no-spec # --no-spec skips test file generation for now
    nest generate service auth --no-spec

    This creates src/auth/auth.module.ts, src/auth/auth.controller.ts, and src/auth/auth.service.ts. The CLI automatically updates auth.module.ts to declare the controller and provider, and imports AuthModule into app.module.ts (we already did this manually, but it's good practice).

  2. OTP Storage (In-Memory): For simplicity, we'll store OTPs in memory within the AuthService. This is suitable for demonstrations or low-traffic scenarios with short expiry times. For production, consider using a cache like Redis or Memcached via @nestjs/cache-manager or storing them in a database.

    typescript
    // src/auth/otp.interface.ts (Create this new file)
    export interface OtpData {
      code: string;
      expiresAt: number; // Store as timestamp (milliseconds)
      attempts: number; // Track verification attempts
    }
    
    // In-memory store (replace with Cache/DB in production)
    export type OtpStore = Map<string, OtpData>; // Key: phone number
  3. Auth Service (AuthService): This service handles OTP generation, storage, validation, and interacts with Plivo.

    typescript
    // src/auth/auth.service.ts
    import { Injectable, Logger, HttpException, HttpStatus, InternalServerErrorException, BadRequestException, UnauthorizedException, NotFoundException } from '@nestjs/common';
    import { ConfigService } from '@nestjs/config';
    import * as Plivo from 'plivo';
    import * as speakeasy from 'speakeasy';
    import { OtpData, OtpStore } from './otp.interface';
    
    @Injectable()
    export class AuthService {
      private readonly logger = new Logger(AuthService.name);
      private plivoClient: Plivo.Client;
      private otpStore: OtpStore = new Map(); // In-memory store
      private readonly otpSecret: string;
      private readonly otpExpirySeconds: number;
      private readonly otpDigits: number;
      private readonly plivoSenderId: string;
      private readonly maxAttempts = 3; // Max verification attempts
    
      constructor(private configService: ConfigService) {
        const authId = this.configService.get<string>('PLIVO_AUTH_ID');
        const authToken = this.configService.get<string>('PLIVO_AUTH_TOKEN');
        this.plivoSenderId = this.configService.get<string>('PLIVO_SENDER_ID');
        this.otpSecret = this.configService.get<string>('OTP_SECRET');
        this.otpExpirySeconds = this.configService.get<number>('OTP_EXPIRY_SECONDS');
        this.otpDigits = this.configService.get<number>('OTP_DIGITS');
    
        if (!authId || !authToken || !this.plivoSenderId || !this.otpSecret) {
          this.logger.error('Plivo or OTP configuration missing in environment variables');
          throw new InternalServerErrorException('Server configuration error.');
        }
    
        this.plivoClient = new Plivo.Client(authId, authToken);
      }
    
      /**
       * Generates an OTP, stores it, and sends it via Plivo SMS.
       * @param phoneNumber The recipient's phone number in E.164 format.
       * @returns Promise<void>
       */
      async sendOtp(phoneNumber: string): Promise<void> {
        this.logger.log(`Attempting to send OTP to ${phoneNumber}`);
    
        // 1. Generate OTP
        // This uses a custom approach based on HOTP principles for stateless, unique code generation.
        // It combines the base secret with the phone number for scoping and uses the current timestamp
        // as a pseudo-counter to ensure uniqueness for consecutive requests to the same number.
        // This differs from standard HOTP where a shared, incrementing counter is typically used.
        const code = speakeasy.hotp({
          secret: this.otpSecret + phoneNumber, // Combine base secret with phone number for uniqueness per user
          digits: this.otpDigits,
          encoding: 'base32',
          counter: Date.now(), // Use timestamp as a simple, unique factor per request
        });
    
        // 2. Store OTP (In-Memory) with expiry and attempt counter
        const expiresAt = Date.now() + this.otpExpirySeconds * 1000;
        this.otpStore.set(phoneNumber, { code, expiresAt, attempts: 0 });
        this.logger.debug(`Stored OTP for ${phoneNumber}: ${code}, expires at ${new Date(expiresAt)}`);
    
        // 3. Send SMS via Plivo
        const messageBody = `Your verification code is: ${code}`;
        try {
          const response = await this.plivoClient.messages.create(
            this.plivoSenderId, // src
            phoneNumber,      // dst
            messageBody,      // text
          );
          this.logger.log(`Plivo send SMS response for ${phoneNumber}: ${JSON.stringify(response)}`);
    
          // Basic check for Plivo success indicator (messageUuid).
          // Note: The Plivo SDK (v4+) might throw errors directly for failures,
          // which are caught by the catch block. Relying solely on this check might
          // not cover all failure scenarios depending on the SDK version and API behavior.
          if (response.messageUuid && response.messageUuid.length > 0) {
             this.logger.log(`Successfully initiated OTP send to ${phoneNumber}`);
          } else {
             this.logger.warn(`Plivo response structure might indicate an issue (or SDK handled error differently) for ${phoneNumber}: ${JSON.stringify(response)}`);
             // If the SDK doesn't throw, check for potential error fields in the response
             if (response.error) {
                throw new Error(response.error);
             }
             // Consider adding more specific checks based on observed Plivo responses if needed.
          }
        } catch (error) {
          this.logger.error(`Failed to send OTP via Plivo to ${phoneNumber}: ${error.message}`, error.stack);
          // Remove OTP from store if sending failed
          this.otpStore.delete(phoneNumber);
          throw new InternalServerErrorException('Failed to send OTP message.');
        }
    
        // Optional: Schedule cleanup for expired OTPs (or clean on access)
        setTimeout(() => {
            const storedOtp = this.otpStore.get(phoneNumber);
            if (storedOtp && storedOtp.code === code) { // Only delete if it hasn't been replaced
                this.otpStore.delete(phoneNumber);
                this.logger.debug(`Expired OTP cleaned up for ${phoneNumber}`);
            }
        }, this.otpExpirySeconds * 1000 + 1000); // Run slightly after expiry
      }
    
      /**
       * Verifies the provided OTP against the stored OTP for the phone number.
       * @param phoneNumber The user's phone number.
       * @param otpCode The OTP code entered by the user.
       * @returns Promise<{ success: boolean; message: string }>
       */
      async verifyOtp(phoneNumber: string, otpCode: string): Promise<{ success: boolean; message: string }> {
        this.logger.log(`Attempting to verify OTP for ${phoneNumber} with code ${otpCode}`);
    
        const storedOtpData = this.otpStore.get(phoneNumber);
    
        if (!storedOtpData) {
          this.logger.warn(`No OTP found for ${phoneNumber}`);
          throw new NotFoundException('OTP not found or expired. Please request a new one.');
        }
    
        const { code: expectedCode, expiresAt, attempts } = storedOtpData;
    
        // 1. Check expiry
        if (Date.now() > expiresAt) {
          this.logger.warn(`OTP expired for ${phoneNumber}`);
          this.otpStore.delete(phoneNumber); // Clean up expired OTP
          throw new UnauthorizedException('OTP has expired. Please request a new one.');
        }
    
        // 2. Check attempts
        if (attempts >= this.maxAttempts) {
            this.logger.warn(`Max verification attempts reached for ${phoneNumber}`);
            this.otpStore.delete(phoneNumber); // Lock out after max attempts
            throw new UnauthorizedException('Maximum verification attempts exceeded. Please request a new OTP.');
        }
    
        // 3. Compare codes
        if (otpCode !== expectedCode) {
            // Increment attempts and update store
            storedOtpData.attempts += 1;
            this.otpStore.set(phoneNumber, storedOtpData);
            this.logger.warn(`Invalid OTP provided for ${phoneNumber}. Attempt ${storedOtpData.attempts}/${this.maxAttempts}`);
            throw new UnauthorizedException(`Invalid OTP. ${this.maxAttempts - storedOtpData.attempts} attempts remaining.`);
        }
    
        // 4. Success - Clear OTP from store
        this.otpStore.delete(phoneNumber);
        this.logger.log(`OTP verification successful for ${phoneNumber}`);
        return { success: true, message: 'OTP verified successfully.' };
      }
    }
    • Constructor: Injects ConfigService, retrieves necessary config values, validates them, and initializes the Plivo client.
    • otpStore: A simple Map to store OtpData keyed by phone number.
    • sendOtp:
      • Generates a unique code using speakeasy.hotp with a custom approach (timestamp as counter, phone in secret).
      • Stores the code, expiry timestamp, and initial attempt count in the otpStore.
      • Uses this.plivoClient.messages.create to send the SMS. Note the src, dst, and text parameters.
      • Includes logging and error handling for the Plivo call, with refined comments about response checking.
      • Sets a setTimeout to clean up the OTP from the store after it expires (a simple cleanup strategy).
    • verifyOtp:
      • Retrieves the stored OTP data.
      • Checks if an OTP exists, if it's expired, or if max attempts have been reached.
      • Compares the user-provided otpCode with the expectedCode.
      • Increments the attempt counter on failure.
      • Deletes the OTP from the store on success, expiry, or max attempts.
      • Throws appropriate HttpException subclasses (NotFoundException, UnauthorizedException) for different failure scenarios, which NestJS automatically maps to HTTP status codes (404, 401).

3. Building API Endpoints with DTOs

Now, let's define the API endpoints and the Data Transfer Objects (DTOs) for request validation.

  1. Create DTOs: Create files for request body definitions.

    typescript
    // src/auth/dto/send-otp.dto.ts
    import { IsNotEmpty, IsPhoneNumber, IsString } from 'class-validator';
    
    export class SendOtpDto {
      @IsNotEmpty()
      @IsString()
      @IsPhoneNumber(null, { message: 'Phone number must be a valid E.164 format phone number (e.g., +14155552671)' }) // Use null for region code to enforce E.164 generally
      phoneNumber: string;
    }
    typescript
    // src/auth/dto/verify-otp.dto.ts
    import { IsNotEmpty, IsString, Length, IsPhoneNumber } from 'class-validator';
    
    export class VerifyOtpDto {
      @IsNotEmpty()
      @IsString()
      @IsPhoneNumber(null, { message: 'Phone number must be a valid E.164 format phone number (e.g., +14155552671)' })
      phoneNumber: string;
    
      @IsNotEmpty()
      @IsString()
      @Length(6, 6, { message: 'OTP must be exactly 6 digits' }) // Adjust length based on OTP_DIGITS env var if dynamic
      otpCode: string;
    }
    • We use decorators from class-validator (@IsNotEmpty, @IsString, @IsPhoneNumber, @Length) to define validation rules.
    • @IsPhoneNumber(null, ...) attempts to validate against E.164 format when no region code is provided. For robust validation, consider libphonenumber-js.
    • The ValidationPipe (enabled globally in main.ts) will automatically use these rules.
  2. Implement Controller (AuthController): Define the routes and link them to the service methods.

    typescript
    // src/auth/auth.controller.ts
    import { Controller, Post, Body, HttpCode, HttpStatus, UseGuards } from '@nestjs/common';
    import { AuthService } from './auth.service';
    import { SendOtpDto } from './dto/send-otp.dto';
    import { VerifyOtpDto } from './dto/verify-otp.dto';
    import { Throttle } from '@nestjs/throttler'; // Import Throttle decorator
    
    @Controller('auth') // Route prefix: /auth
    export class AuthController {
      constructor(private readonly authService: AuthService) {}
    
      @Throttle({ default: { limit: 3, ttl: 60000 } }) // Override global throttle: 3 requests per 60 seconds for this endpoint
      @Post('send-otp')
      @HttpCode(HttpStatus.OK) // Send 200 OK on success (instead of default 201 Created)
      async sendOtp(@Body() sendOtpDto: SendOtpDto): Promise<{ message: string }> {
        await this.authService.sendOtp(sendOtpDto.phoneNumber);
        return { message: 'OTP sent successfully. Please check your phone.' };
      }
    
      @Throttle({ default: { limit: 5, ttl: 60000 } }) // Override global throttle: 5 requests per 60 seconds
      @Post('verify-otp')
      @HttpCode(HttpStatus.OK)
      async verifyOtp(@Body() verifyOtpDto: VerifyOtpDto): Promise<{ success: boolean; message: string }> {
        return this.authService.verifyOtp(verifyOtpDto.phoneNumber, verifyOtpDto.otpCode);
      }
    }
    • @Controller('auth'): Sets the base route for all methods in this controller to /auth.
    • @Post('send-otp') / @Post('verify-otp'): Defines POST endpoints at /auth/send-otp and /auth/verify-otp.
    • @Body(): Injects the validated request body (automatically transformed into the DTO instance by ValidationPipe).
    • @HttpCode(HttpStatus.OK): Sets the default success status code to 200.
    • @Throttle(...): Applies specific rate limits to these endpoints, overriding the global default set in app.module.ts. Adjust limits as needed.
    • The methods simply call the corresponding AuthService methods and return responses. Errors thrown by the service (like UnauthorizedException) are handled automatically by NestJS.
  3. Testing Your NestJS OTP API (curl):

    • Send OTP:

      bash
      curl -X POST http://localhost:3000/auth/send-otp \
      -H "Content-Type: application/json" \
      -d '{
        "phoneNumber": "+14155552671" # Replace with a valid test number
      }'

      Expected JSON Response (Success - 200 OK):

      json
      {
        "message": "OTP sent successfully. Please check your phone."
      }

      Expected JSON Response (Validation Error - 400 Bad Request):

      json
      {
        "message": [
          "Phone number must be a valid E.164 format phone number (e.g., +14155552671)"
        ],
        "error": "Bad Request",
        "statusCode": 400
      }
    • Verify OTP: (Assuming you received an OTP, e.g., 123456)

      bash
      curl -X POST http://localhost:3000/auth/verify-otp \
      -H "Content-Type: application/json" \
      -d '{
        "phoneNumber": "+14155552671",
        "otpCode": "123456" # Replace with the actual OTP received
      }'

      Expected JSON Response (Success - 200 OK):

      json
      {
        "success": true,
        "message": "OTP verified successfully."
      }

      Expected JSON Response (Incorrect/Expired OTP - 401 Unauthorized):

      json
      {
        "message": "Invalid OTP. 2 attempts remaining.",
        "error": "Unauthorized",
        "statusCode": 401
      }

4. Configuring Plivo for SMS Delivery

We've already initialized the client, but let's refine the integration details.

  1. Obtaining Credentials:

    • Log in to your Plivo Console.
    • Navigate to the API Platform section in the main dashboard or sidebar.
    • Under Account, find API Keys & Credentials.
    • Your Auth ID and Auth Token are displayed here. Copy these securely into your .env file.
  2. Getting a Sender ID (Plivo Number):

    • In the Plivo Console, navigate to Phone Numbers -> Buy Numbers.
    • Search for numbers by country, type (Local, Toll-Free), and capabilities (SMS, Voice, MMS). Ensure you select a number with SMS capability enabled.
    • Purchase the desired number.
    • Go to Phone Numbers -> Your Numbers. Find the number you purchased.
    • Copy the full number in E.164 format (e.g., +14155552671) and set it as PLIVO_SENDER_ID in your .env file.
  3. Plivo Client Initialization (Recap): The client is initialized in the AuthService constructor using credentials from ConfigService:

    typescript
    // src/auth/auth.service.ts - Constructor Snippet
    constructor(private configService: ConfigService) {
      // ... retrieve authId, authToken, plivoSenderId ...
      this.plivoClient = new Plivo.Client(authId, authToken);
    }
  4. Sending the SMS (Recap): The core Plivo call happens in sendOtp:

    typescript
    // src/auth/auth.service.ts - sendOtp Snippet
    try {
      const response = await this.plivoClient.messages.create(
        this.plivoSenderId, // Your Plivo Number (Source)
        phoneNumber,      // User's Phone Number (Destination)
        messageBody,      // The OTP message
        // Optional parameters can be added here if needed
        // e.g., { url: 'YOUR_STATUS_CALLBACK_URL' } for delivery reports
      );
      // ... logging and basic response check ...
    } catch (error) {
      // ... error handling ...
    }
  5. Fallback Mechanisms (Considerations):

    • Plivo Verify API: Plivo offers a dedicated Verify API that handles OTP generation, sending (SMS/Voice), retries, and verification in a single workflow. This simplifies implementation but offers less control over the OTP generation/storage process itself. It's a strong alternative for simpler use cases.
    • Voice Fallback: If an SMS fails or isn't delivered promptly, you could implement a fallback to send the OTP via a Plivo Voice call using Text-to-Speech. This requires using the Plivo Voice API (client.calls.create).
    • Retry Logic: While Plivo handles carrier-level retries, you might implement application-level retries (with exponential backoff) in your AuthService for transient network errors when calling the Plivo API itself, using libraries like async-retry.

5. Error Handling and Production Logging

Robust error handling and logging are crucial for production systems.

  1. Error Handling Strategy:

    • Service Level: Throw specific HttpException subclasses (BadRequestException, UnauthorizedException, NotFoundException, InternalServerErrorException) from the AuthService for predictable client errors.
    • Controller Level: Catch errors during Plivo interactions within the service and log them, typically re-throwing an InternalServerErrorException to avoid leaking sensitive details.
    • Global Exception Filter (Optional): For more complex error formatting or centralized error reporting, implement a NestJS Exception Filter.
    • Plivo API Errors: The plivo Node.js library throws errors for API issues (e.g., invalid credentials, insufficient funds). Catch these in the AuthService, log details, and return a generic server error to the client. Consult Plivo API Error Codes for specific meanings.
  2. Logging:

    • Use NestJS's built-in Logger service.
    • Log Levels:
      • log: General information (e.g., "OTP Sent to X", "Verification successful for X").
      • debug: Detailed internal state (e.g., "Stored OTP for X: code, expiresAt"). Enable only in development or for troubleshooting.
      • warn: Potential issues or expected errors (e.g., "Invalid OTP provided", "Max attempts reached").
      • error: Critical failures (e.g., "Failed to send via Plivo", "Config missing"). Include error messages and stack traces.
    • Format: NestJS default logger includes timestamp, log level, and context (AuthService). Consider JSON logging libraries (like pino) for easier parsing by log aggregation systems (e.g., Datadog, Splunk, ELK stack).
    • Example Log Analysis: If users report not receiving OTPs, search logs for Failed to send OTP via Plivo to [phoneNumber] or check Plivo's response logs for specific error messages or messageUuid. If users report invalid OTPs, check logs for Invalid OTP provided for [phoneNumber] or OTP expired for [phoneNumber].
  3. Retry Mechanisms:

    • Plivo API Calls: As mentioned, use async-retry or similar if you frequently encounter transient network errors connecting to Plivo. Wrap the this.plivoClient.messages.create call.

      typescript
      // Example using async-retry (dependency added in Section 1)
      import * as retry from 'async-retry';
      import * as Plivo from 'plivo'; // Assuming Plivo types if needed for error checking
      
      // Inside sendOtp:
      // try {
      //   const response = await retry(async (bail, attempt) => {
      //     this.logger.debug(`Attempt ${attempt} to send SMS via Plivo to ${phoneNumber}`);
      //     try {
      //       const res = await this.plivoClient.messages.create(/*...*/);
      //       // Check for Plivo-specific logical errors that shouldn't be retried
      //       // if (isNonRetryablePlivoError(res.error)) {
      //       //   bail(new Error(`Non-retryable Plivo error: ${res.error}`));
      //       //   return; // Important: return undefined or similar after bail
      //       // }
      //       // Check for success based on Plivo response structure
      //       if (!res.messageUuid || res.messageUuid.length === 0) {
      //          // Potentially throw an error to trigger retry if response indicates failure
      //          throw new Error(`Plivo response indicates potential failure: ${JSON.stringify(res)}`);
      //       }
      //       return res; // Return successful response
      //     } catch (error) {
      //       // Check if the error is retryable (e.g., network timeout, 5xx from Plivo)
      //       // if (isRetryableNetworkError(error) || isRetryablePlivoStatus(error?.status)) {
      //       //   this.logger.warn(`Retryable error on attempt ${attempt} sending to ${phoneNumber}: ${error.message}`);
      //       //   throw error; // Throw error to trigger retry
      //       // } else {
      //       //   // Non-retryable error (e.g., 4xx client error, invalid number)
      //       //   bail(error); // Prevent further retries
      //       //   return; // Important: return undefined or similar after bail
      //       // }
      //       // Simplified: Assume most Plivo SDK errors might be retryable unless known otherwise
      //       this.logger.warn(`Retryable error on attempt ${attempt} sending to ${phoneNumber}: ${error.message}`);
      //       throw error; // Throw to trigger retry
      //     }
      //   }, {
      //     retries: 3, // Number of retries
      //     factor: 2, // Exponential backoff factor
      //     minTimeout: 1000, // Minimum delay ms
      //     onRetry: (error, attempt) => {
      //       this.logger.warn(`Retrying Plivo SMS send to ${phoneNumber} (Attempt ${attempt}) due to: ${error.message}`);
      //     }
      //   });
      //
      //   // Handle final response after successful retry or if retries failed
      //   this.logger.log(`Plivo send SMS response for ${phoneNumber}: ${JSON.stringify(response)}`);
      //   // ... rest of the success/failure logic based on the final 'response' ...
      //
      // } catch (error) {
      //   // This catch block now handles errors after all retries have failed,
      //   // or non-retryable errors passed via bail().
      //   this.logger.error(`Failed to send OTP via Plivo to ${phoneNumber} after retries: ${error.message}`, error.stack);
      //   this.otpStore.delete(phoneNumber);
      //   throw new InternalServerErrorException('Failed to send OTP message.');
      // }

Frequently Asked Questions

how to send sms otp with nestjs

Use the Plivo Node.js SDK within a NestJS service to send SMS messages containing one-time passwords (OTPs). The `@plivo/rest-client` library provides methods for sending SMS messages given a Plivo phone number, recipient's number, and the OTP.

what is plivo used for in nestjs otp

Plivo is a cloud communications platform used to send the actual SMS messages containing the OTPs. The provided code example uses the Plivo Node.js helper library to interact with the Plivo API for sending SMS messages reliably.

why use nestjs for 2fa implementation

NestJS provides a structured, scalable framework for building server-side applications in Node.js. Its features like dependency injection and modules make it well-suited for complex tasks like two-factor authentication (2FA) implementation using OTP, as shown in the tutorial.

when should i implement rate limiting in nestjs

Rate limiting is crucial in production to prevent abuse and protect your application from excessive requests. This article recommends using the `@nestjs/throttler` module in your NestJS application to implement rate limiting, especially for endpoints like OTP sending, as shown in the example code with the `Throttle` decorator.

can i use redis for otp storage with nestjs

Yes, the article suggests using a cache like Redis or Memcached for OTP storage in production, though the example utilizes an in-memory store (`Map`) for simplicity. Integrate Redis by installing `@nestjs/cache-manager` and including it within the modules using Redis for OTP storage and retrieval.

how to setup plivo for sending sms otp

Sign up for a Plivo account, obtain API keys (Auth ID and Auth Token), and rent a Plivo phone number capable of sending SMS. Configure these credentials within your NestJS project, making sure to keep your Auth Token secret (e.g., within a `.env` file).

what is speakeasy used for

The `speakeasy` library is used for generating time-based one-time passwords (TOTP) and HMAC-based one-time passwords (HOTP). The example code utilizes it within a NestJS service to generate a unique OTP code to send to a user for 2FA.

how to secure plivo credentials in nestjs

Store your Plivo API keys (Auth ID and Auth Token) in a `.env` file within your project, as shown in the guide. Ensure this file is added to your `.gitignore` to prevent it from being committed to version control and exposed publicly.

what is the purpose of otp expiry

OTPs have an expiry time to limit their validity, enhancing security. In the example, the `OTP_EXPIRY_SECONDS` environment variable (with a default of 300 seconds/5 minutes) sets how long each OTP is valid before it must be regenerated by the user.

how to handle plivo api errors in nestjs

The Plivo Node.js library will throw errors for issues like invalid credentials or network problems. Use a `try-catch` block within your NestJS service to handle these exceptions, log the errors for debugging, and return appropriate responses to the client, typically a generic server error message to avoid exposing sensitive details.

how to verify otp in nestjs

Create a NestJS service method (and corresponding controller endpoint) to receive the user's phone number and OTP. The example includes a dedicated `AuthService` for verification with the `verifyOtp` method, comparing the received OTP against the stored, unexpired OTP to verify user authentication.

how to generate otp in nestjs with plivo

The tutorial uses `speakeasy.hotp` in combination with a secret key, the user's phone number, and the current timestamp to generate a one-time password (OTP). This OTP is then sent to the user via SMS using the Plivo API.

why is two factor authentication important

Two-factor authentication (2FA), such as using SMS OTP, adds an extra layer of security, protecting user accounts even if passwords are compromised. If an attacker gains access to a user's password, they still need the temporary OTP to access the account.

what are the prerequisites for nestjs plivo integration

You'll need Node.js and npm/yarn installed, a Plivo account with API keys and a rented phone number, and a basic understanding of NestJS, TypeScript, and a suitable code editor. The guide also suggests familiarity with making API requests using tools like `curl` or Postman.