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.
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)
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:
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.
Install NestJS CLI: If you don't have it, install it globally.
Bash
npm install -g @nestjs/cli
Create New NestJS Project:
Bash
nest new plivo-otp-app
Choose your preferred package manager (npm or yarn).
Navigate to Project Directory:
Bash
cd plivo-otp-app
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).
Project Structure: NestJS CLI creates a standard structure. We'll primarily work within the src directory, creating modules, controllers, and services.
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:
.env
# .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.
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.
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.
Enable Validation Pipe: Globally enable the ValidationPipe in src/main.ts to automatically validate incoming DTOs based on class-validator decorators.
TypeScript
// src/main.tsimport { NestFactory } from'@nestjs/core';
import { AppModule } from'./app.module';
import { ValidationPipe } from'@nestjs/common';
import { ConfigService } from'@nestjs/config';
asyncfunctionbootstrap() {
const app = awaitNestFactory.create(AppModule);
const configService = app.get(ConfigService); // Get config service instance// Enable global validation pipe
app.useGlobalPipes(newValidationPipe({
whitelist: true, // Strip properties not defined in DTOtransform: true, // Automatically transform payloads to DTO instancestransformOptions: {
enableImplicitConversion: true, // Allow basic type conversions
},
}));
const port = configService.get<number>('PORT') || 3000; // Use env var for portawait 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:
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).
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)exportinterfaceOtpData {
code: string;
expiresAt: number; // Store as timestamp (milliseconds)attempts: number; // Track verification attempts
}
// In-memory store (replace with Cache/DB in production)exporttypeOtpStore = 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.tsimport { Injectable, Logger, HttpException, HttpStatus, InternalServerErrorException, BadRequestException, UnauthorizedException, NotFoundException } from'@nestjs/common';
import { ConfigService } from'@nestjs/config';
import * asPlivofrom'plivo';
import * as speakeasy from'speakeasy';
import { OtpData, OtpStore } from'./otp.interface';
@Injectable()
exportclassAuthService {
privatereadonly logger = newLogger(AuthService.name);
privateplivoClient: Plivo.Client;
privateotpStore: OtpStore = newMap(); // In-memory storeprivatereadonlyotpSecret: string;
privatereadonlyotpExpirySeconds: number;
privatereadonlyotpDigits: number;
privatereadonlyplivoSenderId: string;
privatereadonly maxAttempts = 3; // Max verification attemptsconstructor(privateconfigService: 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');
thrownewInternalServerErrorException('Server configuration error.');
}
this.plivoClient = newPlivo.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>
*/asyncsendOtp(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 userdigits: 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 counterconst expiresAt = Date.now() + this.otpExpirySeconds * 1000;
this.otpStore.set(phoneNumber, { code, expiresAt, attempts: 0 });
this.logger.debug(`Stored OTP for ${phoneNumber}: ${code}, expires at ${newDate(expiresAt)}`);
// 3. Send SMS via Plivoconst messageBody = `Your verification code is: ${code}`;
try {
const response = awaitthis.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 responseif (response.error) {
thrownewError(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 failedthis.otpStore.delete(phoneNumber);
thrownewInternalServerErrorException('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 replacedthis.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 }>
*/asyncverifyOtp(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}`);
thrownewNotFoundException('OTP not found or expired. Please request a new one.');
}
const { code: expectedCode, expiresAt, attempts } = storedOtpData;
// 1. Check expiryif (Date.now() > expiresAt) {
this.logger.warn(`OTP expired for ${phoneNumber}`);
this.otpStore.delete(phoneNumber); // Clean up expired OTPthrownewUnauthorizedException('OTP has expired. Please request a new one.');
}
// 2. Check attemptsif (attempts >= this.maxAttempts) {
this.logger.warn(`Max verification attempts reached for ${phoneNumber}`);
this.otpStore.delete(phoneNumber); // Lock out after max attemptsthrownewUnauthorizedException('Maximum verification attempts exceeded. Please request a new OTP.');
}
// 3. Compare codesif (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}`);
thrownewUnauthorizedException(`Invalid OTP. ${this.maxAttempts - storedOtpData.attempts} attempts remaining.`);
}
// 4. Success - Clear OTP from storethis.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.
Create DTOs: Create files for request body definitions.
TypeScript
// src/auth/dto/send-otp.dto.tsimport { IsNotEmpty, IsPhoneNumber, IsString } from'class-validator';
exportclassSendOtpDto {
@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 generallyphoneNumber: string;
}
TypeScript
// src/auth/dto/verify-otp.dto.tsimport { IsNotEmpty, IsString, Length, IsPhoneNumber } from'class-validator';
exportclassVerifyOtpDto {
@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 dynamicotpCode: 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.
Implement Controller (AuthController): Define the routes and link them to the service methods.
@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.
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
}'
Sending the SMS (Recap): The core Plivo call happens in sendOtp:
TypeScript
// src/auth/auth.service.ts - sendOtp Snippettry {
const response = awaitthis.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 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.
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.
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.
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].
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 * asPlivofrom'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.');// }
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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
speakeasyfor 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 likeotplib(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:
Technologies Used
plivonpm package, actively maintained) to send OTP messages reliably.otplibinstead for production.How SMS OTP Authentication Works
The basic flow for OTP verification involves these steps:
What You'll Build
By the end of this guide, you will have:
AuthModule) responsible for OTP logic.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.Prerequisites
curlor Postman).SMS OTP Security Best Practices
@nestjs/cache-managerwith TTL-based expiration. Database storage requires manual cleanup jobs.crypto.randomBytes(32).toString('base32')). Source: NIST SP 800-63B (2024)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.
Create New NestJS Project:
Choose your preferred package manager (npm or yarn).
Navigate to Project Directory:
cd plivo-otp-appInstall Dependencies:
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/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: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 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.// 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.// 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:
nest 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.// 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 numberAuth Service (
AuthService): This service handles OTP generation, storage, validation, and interacts with Plivo.// 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.' }; } }ConfigService, retrieves necessary config values, validates them, and initializes the Plivo client.otpStore: A simpleMapto storeOtpDatakeyed by phone number.sendOtp:speakeasy.hotpwith a custom approach (timestamp as counter, phone in secret).otpStore.this.plivoClient.messages.createto send the SMS. Note thesrc,dst, andtextparameters.setTimeoutto clean up the OTP from the store after it expires (a simple cleanup strategy).verifyOtp:otpCodewith theexpectedCode.HttpExceptionsubclasses (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.
Create DTOs: Create files for request body definitions.
// 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; }// 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; }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.ValidationPipe(enabled globally inmain.ts) will automatically use these rules.Implement Controller (
AuthController): Define the routes and link them to the service methods.// 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.AuthServicemethods and return responses. Errors thrown by the service (likeUnauthorizedException) are handled automatically by NestJS.Testing Your NestJS OTP API (
curl):Send OTP:
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):
{ "message": "OTP sent successfully. Please check your phone." }Expected JSON Response (Validation Error - 400 Bad Request):
{ "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)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):
{ "success": true, "message": "OTP verified successfully." }Expected JSON Response (Incorrect/Expired OTP - 401 Unauthorized):
{ "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:
.envfile.Getting a Sender ID (Plivo Number):
+14155552671) and set it asPLIVO_SENDER_IDin your.envfile.Plivo Client Initialization (Recap): The client is initialized in the
AuthServiceconstructor using credentials fromConfigService:// 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:// 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):
client.calls.create).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:
HttpExceptionsubclasses (BadRequestException,UnauthorizedException,NotFoundException,InternalServerErrorException) from theAuthServicefor predictable client errors.InternalServerErrorExceptionto avoid leaking sensitive details.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.Logging:
Loggerservice.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.AuthService). Consider JSON logging libraries (likepino) for easier parsing by log aggregation systems (e.g., Datadog, Splunk, ELK stack).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].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.// 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