code examples
code examples
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:
plivonpm 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
otplibinstead for production.
How SMS OTP Authentication Works
The basic flow for OTP verification involves these steps:
+-----------+ 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
curlor 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-managerwith 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.
-
Install NestJS CLI: If you don't have it, install it globally.
bashnpm install -g @nestjs/cli -
Create New NestJS Project:
bashnest new plivo-otp-appChoose your preferred package manager (npm or yarn).
-
Navigate to Project Directory:
bashcd plivo-otp-app -
Install Dependencies:
bashnpm 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/otplibplivo: 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 considerotplibfor production. Source: Speakeasy GitHub (2024)@types/speakeasy: TypeScript definitions forspeakeasy.@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).
-
Project Structure: NestJS CLI creates a standard structure. We'll primarily work within the
srcdirectory, creating modules, controllers, and services. -
Environment Variables (
.env): Create a.envfile 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 windowPLIVO_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 byspeakeasyfor 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.
-
Configure
ConfigModule: Import and configure theConfigModulein your main application module (src/app.module.ts) to load the.envfile. 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.envand makesConfigServiceinjectable anywhere.ThrottlerModule.forRoot([...]): Configures the rate limiter using environment variables. We convert TTL to milliseconds as required by the module.APP_GUARD: Applies theThrottlerGuardto all routes in the application.
-
Enable Validation Pipe: Globally enable the
ValidationPipeinsrc/main.tsto automatically validate incoming DTOs based onclass-validatordecorators.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.
-
Generate Auth Module, Controller, and Service: Use the NestJS CLI:
bashnest generate module auth nest generate controller auth --no-spec # --no-spec skips test file generation for now nest generate service auth --no-specThis creates
src/auth/auth.module.ts,src/auth/auth.controller.ts, andsrc/auth/auth.service.ts. The CLI automatically updatesauth.module.tsto declare the controller and provider, and importsAuthModuleintoapp.module.ts(we already did this manually, but it's good practice). -
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-manageror 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 -
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 simpleMapto storeOtpDatakeyed by phone number.sendOtp:- Generates a unique code using
speakeasy.hotpwith 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.createto send the SMS. Note thesrc,dst, andtextparameters. - Includes logging and error handling for the Plivo call, with refined comments about response checking.
- Sets a
setTimeoutto clean up the OTP from the store after it expires (a simple cleanup strategy).
- Generates a unique code using
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
otpCodewith theexpectedCode. - Increments the attempt counter on failure.
- Deletes the OTP from the store on success, expiry, or max attempts.
- Throws appropriate
HttpExceptionsubclasses (NotFoundException,UnauthorizedException) for different failure scenarios, which NestJS automatically maps to HTTP status codes (404, 401).
- Constructor: Injects
3. Building API Endpoints with DTOs
Now, let's define the API endpoints and the Data Transfer Objects (DTOs) for request validation.
-
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, considerlibphonenumber-js.- The
ValidationPipe(enabled globally inmain.ts) will automatically use these rules.
- We use decorators from
-
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-otpand/auth/verify-otp.@Body(): Injects the validated request body (automatically transformed into the DTO instance byValidationPipe).@HttpCode(HttpStatus.OK): Sets the default success status code to 200.@Throttle(...): Applies specific rate limits to these endpoints, overriding the global default set inapp.module.ts. Adjust limits as needed.- The methods simply call the corresponding
AuthServicemethods and return responses. Errors thrown by the service (likeUnauthorizedException) are handled automatically by NestJS.
-
Testing Your NestJS OTP API (
curl):-
Send OTP:
bashcurl -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)bashcurl -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.
-
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
.envfile.
-
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 asPLIVO_SENDER_IDin your.envfile.
-
Plivo Client Initialization (Recap): The client is initialized in the
AuthServiceconstructor using credentials fromConfigService:typescript// src/auth/auth.service.ts - Constructor Snippet constructor(private configService: ConfigService) { // ... retrieve authId, authToken, plivoSenderId ... this.plivoClient = new Plivo.Client(authId, authToken); } -
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 ... } -
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
AuthServicefor transient network errors when calling the Plivo API itself, using libraries likeasync-retry.
5. Error Handling and Production Logging
Robust error handling and logging are crucial for production systems.
-
Error Handling Strategy:
- Service Level: Throw specific
HttpExceptionsubclasses (BadRequestException,UnauthorizedException,NotFoundException,InternalServerErrorException) from theAuthServicefor predictable client errors. - Controller Level: Catch errors during Plivo interactions within the service and log them, typically re-throwing an
InternalServerErrorExceptionto 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
plivoNode.js library throws errors for API issues (e.g., invalid credentials, insufficient funds). Catch these in theAuthService, log details, and return a generic server error to the client. Consult Plivo API Error Codes for specific meanings.
- Service Level: Throw specific
-
Logging:
- Use NestJS's built-in
Loggerservice. - 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 (likepino) 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 ormessageUuid. If users report invalid OTPs, check logs forInvalid OTP provided for [phoneNumber]orOTP expired for [phoneNumber].
- Use NestJS's built-in
-
Retry Mechanisms:
-
Plivo API Calls: As mentioned, use
async-retryor similar if you frequently encounter transient network errors connecting to Plivo. Wrap thethis.plivoClient.messages.createcall.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.'); // }
-
Related Resources
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.