NestJS Two-Factor Authentication with Vonage: Complete OTP Implementation Guide
Learn how to implement secure two-factor authentication (2FA) and OTP verification in NestJS using Vonage Verify API. Complete tutorial with code examples, security best practices, and production deployment guide.
This comprehensive guide walks you through implementing two-factor authentication (2FA) and One-Time Password (OTP) verification in NestJS using the Vonage Verify API. You'll build secure, production-ready API endpoints for sending verification codes via SMS and validating user-submitted codes.
Adding two-factor authentication significantly enhances application security by requiring users to provide something they have (access to their phone) in addition to something they know (like a password), mitigating risks associated with compromised credentials.
What You'll Build: Secure Two-Factor Authentication in NestJS
What We'll Build:
A NestJS backend application with API endpoints to:
Initiate an OTP verification request to a user's phone number via Vonage.
Verify the OTP code submitted by the user against the Vonage request.
Secure handling of API credentials.
Basic error handling and logging.
Input validation for API requests.
Problem Solved: Implementing a reliable and secure second factor of authentication for user actions like login, password reset, or sensitive operations.
Technologies Used:
Node.js: The runtime environment.
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) streamline development.
Vonage Verify API: A service that handles the complexities of sending OTPs across multiple channels (SMS, Voice) and managing the verification lifecycle. We use the @vonage/server-sdk.
dotenv / @nestjs/config: For managing environment variables securely.
class-validator / class-transformer: For robust request payload validation.
Node.js (v20 or v22 recommended) and npm/yarn installed. Note: Node.js v16 reached end-of-life on September 11, 2023, and v18 will reach EOL on April 30, 2025. For production applications, use Node.js v20 "Iron" (Maintenance LTS until April 2026) or v22 "Jod" (Active LTS until October 2025, Maintenance until April 2027).
A Vonage API account. You can sign up for free credits.
Your Vonage API Key and API Secret (found on your Vonage dashboard).
Setting Up Your NestJS Project for Vonage Integration
Let's start by creating a new NestJS project and installing the necessary dependencies.
Create a new NestJS Project:
Open your terminal and run:
Bash
nest new vonage-otp-nestjs
cd vonage-otp-nestjs
Choose your preferred package manager (npm or yarn) when prompted.
Install Dependencies:
We need the Vonage Server SDK, NestJS config module for environment variables, and class-validator/transformer for input validation.
Bash
# Using npm
npm install @vonage/server-sdk @nestjs/config class-validator class-transformer
# Or using yarn
yarn add @vonage/server-sdk @nestjs/config class-validator class-transformer
Set up Environment Variables:
Create a .env file in the root of your project. This file will store your sensitive Vonage credentials. Never commit this file to version control. Add a .gitignore entry for .env if it's not already there.
.env
ini
# Vonage API CredentialsVONAGE_API_KEY=YOUR_VONAGE_API_KEY
VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
VONAGE_BRAND_NAME=MyApp # Optional: Brand name shown in SMS message
Replace YOUR_VONAGE_API_KEY and YOUR_VONAGE_API_SECRET with your actual credentials from the Vonage Dashboard.
Configure NestJS ConfigModule:
Import and configure the ConfigModule in your main application module (src/app.module.ts) to load environment variables from the .env file.
src/app.module.ts
TypeScript
import { Module } from'@nestjs/common';
import { ConfigModule } from'@nestjs/config';
import { AppController } from'./app.controller';
import { AppService } from'./app.service';
import { OtpModule } from'./otp/otp.module'; // We will create this next@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // Make ConfigModule available globallyenvFilePath: '.env', // Specify the env file path
}),
OtpModule, // Import the OTP module
],
controllers: [AppController],
providers: [AppService],
})
exportclassAppModule {}
Using isGlobal: true makes the ConfigService available throughout your application without needing to import ConfigModule into every feature module.
Enable Validation Pipe Globally:
In src/main.ts, enable the built-in ValidationPipe to automatically validate incoming request payloads based on DTOs (Data Transfer Objects).
src/main.ts
TypeScript
import { NestFactory } from'@nestjs/core';
import { AppModule } from'./app.module';
import { ValidationPipe, Logger } from'@nestjs/common'; // Import Loggerimport { ConfigService } from'@nestjs/config'; // Import ConfigServiceasyncfunctionbootstrap() {
const app = awaitNestFactory.create(AppModule);
const logger = newLogger('Bootstrap'); // Create a logger instance// Get ConfigService to read PORT potentiallyconst configService = app.get(ConfigService);
const port = configService.get<number>('PORT') || 3000; // Use PORT from .env or default to 3000// Enable global validation pipe
app.useGlobalPipes(
newValidationPipe({
whitelist: true, // Strip properties not defined in DTOtransform: true, // Automatically transform payloads to DTO instancesforbidNonWhitelisted: true, // Throw error if non-whitelisted properties are provided
}),
);
await app.listen(port);
logger.log(`Application listening on port ${port}`); // Log the port
}
bootstrap();
The options whitelist and forbidNonWhitelisted enhance security by ensuring only expected data fields are processed. transform automatically converts incoming JSON into typed DTO instances.
Implementing the Vonage Verify API Service
We'll encapsulate the Vonage SDK interactions within a dedicated NestJS service.
Generate the OTP Module and Service:
Use the NestJS CLI to generate a module and service for OTP functionality.
The --flat flag prevents the CLI from creating an extra sub-directory for the service.
Implement the VonageService:
This service will handle initializing the Vonage client and calling the Verify API methods.
src/otp/vonage.service.ts
TypeScript
import { Injectable, Logger, InternalServerErrorException, BadRequestException } from'@nestjs/common';
import { ConfigService } from'@nestjs/config';
import { Vonage } from'@vonage/server-sdk';
import { VerifyRequestResponse, VerifyCheckResponse } from'@vonage/verify'; // Import response types@Injectable()
exportclassVonageService {
privatereadonly logger = newLogger(VonageService.name);
privatevonage: Vonage;
privatebrandName: string;
constructor(privateconfigService: ConfigService) {
const apiKey = this.configService.get<string>('VONAGE_API_KEY');
const apiSecret = this.configService.get<string>('VONAGE_API_SECRET');
this.brandName = this.configService.get<string>('VONAGE_BRAND_NAME', 'MyApp'); // Default brand nameif (!apiKey || !apiSecret) {
this.logger.error('Vonage API Key or Secret not found in environment variables.');
thrownewInternalServerErrorException('Vonage API credentials are missing.');
}
this.vonage = newVonage({ apiKey, apiSecret });
this.logger.log('Vonage client initialized successfully.');
}
/**
* Sends an OTP verification code to the specified phone number.
* @param phoneNumber The E.164 formatted phone number.
* @returns The request ID for the verification attempt.
* @throws InternalServerErrorException on API call failure.
* @throws BadRequestException on Vonage API error (e.g., invalid number).
*/asyncsendOtp(phoneNumber: string): Promise<{ request_id: string }> {
this.logger.log(`Sending OTP request to phone number: ${phoneNumber}`);
try {
constresult: VerifyRequestResponse = awaitthis.vonage.verify.start({
number: phoneNumber,
brand: this.brandName,
// workflow_id: 6, // Optional: Specify workflow (e.g., 6 for 6-digit code)// code_length: 6, // Optional: Specify code length (4 or 6)
});
// Log essential response detailsthis.logger.log(
`Vonage verify request response: Status=${result.status}, RequestID=${result.request_id}` +
(result.error_text ? `, Error=${result.error_text}` : '')
);
// Check Vonage API statusif (result.status !== '0') {
this.logger.error(`Vonage API Error: ${result.error_text} (Status: ${result.status})`);
// Map specific errors if needed, otherwise throw a generic bad requestthrownewBadRequestException(`Failed to send OTP: ${result.error_text || 'Invalid request'}`);
}
this.logger.log(`OTP request sent successfully. Request ID: ${result.request_id}`);
return { request_id: result.request_id };
} catch (error) {
this.logger.error('Error calling Vonage verify API', error.stack);
// Handle specific SDK errors or re-throw generic errorsif (error instanceofBadRequestException) {
throw error; // Re-throw known bad requests
}
thrownewInternalServerErrorException('Failed to send OTP due to an internal error.');
}
}
/**
* Verifies the OTP code submitted by the user.
* @param requestId The request ID from the sendOtp call.
* @param code The OTP code entered by the user.
* @returns True if verification is successful.
* @throws BadRequestException if the code is invalid or the request expired.
* @throws InternalServerErrorException on API call failure.
*/asyncverifyOtp(requestId: string, code: string): Promise<boolean> {
this.logger.log(`Verifying OTP for Request ID: ${requestId} with code: ${code}`);
try {
constresult: VerifyCheckResponse = awaitthis.vonage.verify.check(requestId, code);
// Log essential response detailsthis.logger.log(
`Vonage verify check response: Status=${result.status}, RequestID=${requestId}` +
(result.error_text ? `, Error=${result.error_text}` : '')
);
// Status '0' means successful verificationif (result.status === '0') {
this.logger.log(`OTP verification successful for Request ID: ${requestId}`);
returntrue;
} else {
// Handle specific error statuses from Vonage// See: https://developer.vonage.com/api/verify#check-errorsthis.logger.warn(`OTP verification failed for Request ID: ${requestId}. Status: ${result.status}, Error: ${result.error_text}`);
thrownewBadRequestException(`OTP verification failed: ${result.error_text || 'Invalid code or request expired.'}`);
}
} catch (error) {
this.logger.error(`Error calling Vonage check API for Request ID: ${requestId}`, error.stack);
// Re-throw known bad requests or throw a generic internal errorif (error instanceofBadRequestException) {
throw error;
}
thrownewInternalServerErrorException('Failed to verify OTP due to an internal error.');
}
}
}
Injecting ConfigService allows secure access to API keys from environment variables rather than hardcoding them. Using the Logger is essential for debugging and monitoring API interactions. Checking the API key/secret in the constructor ensures a fast failure if essential configuration is missing. The request_id is returned because the client needs this ID for the subsequent verification request.
Update the OtpModule:
Make sure VonageService is provided and exported by the OtpModule.
src/otp/otp.module.ts
TypeScript
import { Module } from'@nestjs/common';
import { VonageService } from'./vonage.service';
import { OtpController } from'./otp.controller'; // We will create this next@Module({
providers: [VonageService],
exports: [VonageService], // Export if needed by other modulescontrollers: [OtpController], // Add the controller
})
exportclassOtpModule {}
Building REST API Endpoints for OTP Verification
Now, let's create the controller that exposes the OTP functionality via REST endpoints and the DTOs for request validation.
Generate the OTP Controller:
Bash
nest generate controller otp
Create Data Transfer Objects (DTOs):
Create DTO files to define the expected shape and validation rules for incoming request bodies.
src/otp/dto/send-otp.dto.ts
TypeScript
import { IsNotEmpty, IsString, IsPhoneNumber } from'class-validator';
exportclassSendOtpDto {
@IsNotEmpty()
@IsString()
@IsPhoneNumber(null, { message: 'Phone number must be a valid E.164 format (e.g., +14155552671)' }) // Use null for region for global numbersphone: string;
}
Using IsPhoneNumber ensures the input is a valid phone number format (E.164 is recommended for Vonage).
src/otp/dto/verify-otp.dto.ts
TypeScript
import { IsNotEmpty, IsString, Length } from'class-validator';
exportclassVerifyOtpDto {
@IsNotEmpty()
@IsString()
// Add validation based on your request_id format if known, otherwise basic checks sufficerequestId: string;
@IsNotEmpty()
@IsString()
@Length(4, 6, { message: 'OTP code must be between 4 and 6 digits' }) // Adjust length (4 or 6) to match the 'code_length' used in sendOtp (Vonage API default is 4 if unspecified)code: string;
}
Using Length ensures the code matches the expected format. Vonage codes are typically 4 or 6 digits; match this to your configuration or the default (4).
Implement the OtpController:
Inject the VonageService and define the API endpoints using decorators.
src/otp/otp.controller.ts
TypeScript
import { Controller, Post, Body, HttpCode, HttpStatus, Logger, BadRequestException } from'@nestjs/common';
import { VonageService } from'./vonage.service';
import { SendOtpDto } from'./dto/send-otp.dto';
import { VerifyOtpDto } from'./dto/verify-otp.dto';
@Controller('otp') // Route prefix: /otpexportclassOtpController {
privatereadonly logger = newLogger(OtpController.name);
constructor(privatereadonlyvonageService: VonageService) {}
/**
* Endpoint to request an OTP code to be sent to a phone number.
*/@Post('/send') // Full route: POST /otp/send@HttpCode(HttpStatus.OK) // Send 200 OK on success instead of 201 CreatedasyncrequestOtp(@Body() sendOtpDto: SendOtpDto): Promise<{ requestId: string; message: string }> {
this.logger.log(`Received request to send OTP to: ${sendOtpDto.phone}`);
try {
const result = awaitthis.vonageService.sendOtp(sendOtpDto.phone);
return {
requestId: result.request_id,
message: 'OTP request sent successfully. Please check your phone.',
};
} catch (error) {
// Log and re-throw errors handled by the service or global exception filtersthis.logger.error(`Error in requestOtp: ${error.message}`, error.stack);
throw error; // Let NestJS handle the standardized error response
}
}
/**
* Endpoint to verify an OTP code.
*/@Post('/verify') // Full route: POST /otp/verify@HttpCode(HttpStatus.OK)
asyncverifyOtp(@Body() verifyOtpDto: VerifyOtpDto): Promise<{ verified: boolean; message: string }> {
this.logger.log(`Received request to verify OTP for request ID: ${verifyOtpDto.requestId}`);
try {
// The VonageService.verifyOtp method throws BadRequestException on failure,// so we only need to handle the success case here.awaitthis.vonageService.verifyOtp(
verifyOtpDto.requestId,
verifyOtpDto.code,
);
// If the above line doesn't throw, verification was successful.return { verified: true, message: 'OTP verified successfully.' };
} catch (error) {
// Log errors thrown by the service (like BadRequestException for failed verification)// and re-throw them for consistent global error handling.this.logger.error(`Error in verifyOtp: ${error.message}`, error.stack);
throw error;
}
}
}
The @Body() decorator extracts and automatically validates the request body using the provided DTO and the global ValidationPipe. Using HttpCode(HttpStatus.OK) is often more appropriate than the default 201 Created for POST requests that don't create a new resource.
Testing Endpoints with curl:
Start the Application:
Bash
npm run start:dev
Send OTP Request:
Replace +14155551234 with a real phone number you have access to (in E.164 format).
{""statusCode"":400,""message"":""OTP verification failed: The code provided does not match the expected value."",""error"":""Bad Request""}
Configuring Vonage API Credentials and Settings
Let's ensure the Vonage integration is correctly configured.
Obtaining API Credentials:
Log in to your Vonage API Dashboard.
Your API Key and API Secret are displayed prominently on the main dashboard page (usually top-left).
Navigate to API Settings in the left-hand menu if you need to regenerate secrets or manage other settings. No specific dashboard navigation is usually needed just to get the initial keys.
Environment Variables:
As set up in Section 1, Step 3:
.env
ini
# Vonage API CredentialsVONAGE_API_KEY=YOUR_VONAGE_API_KEY # Purpose: Authenticates your application with the Vonage API. Format: Alphanumeric string. Obtain: Vonage Dashboard homepage.VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET # Purpose: Secret key paired with the API Key for authentication. Format: Alphanumeric string. Obtain: Vonage Dashboard homepage.VONAGE_BRAND_NAME=MyApp # Purpose: Displayed as the sender in the OTP message. Format: String (up to 11 alphanumeric chars or 16 numeric chars). Obtain: Define yourself. Optional.
Secure Handling:
.env File: Keep API keys in the .env file.
.gitignore: Ensure .env is listed in your .gitignore file to prevent accidental commits.
Deployment: Use your hosting provider's mechanism for setting environment variables securely (e.g., secrets management, environment configuration panels). Do not hardcode keys in your deployment scripts or code.
Fallback Mechanisms:
The Vonage Verify API itself has built-in fallbacks (e.g., SMS followed by Voice call if configured or default). For application-level resilience:
Error Handling: The VonageService includes try...catch blocks. If a call to sendOtp or verifyOtp fails due to network issues or Vonage downtime, the InternalServerErrorException will be thrown.
Retry Strategy (Client-Side): Your client application (web/mobile) could implement a simple retry mechanism (e.g., allow the user to request a new code after a delay) if the initial API call fails. Server-side retries for sending the same OTP request are generally not recommended as Vonage manages the delivery attempts.
Error Handling and Logging Best Practices
We've already incorporated basic logging and error handling. Let's refine it.
Consistent Error Handling:
Service Layer: The VonageService catches errors during SDK calls. It logs detailed errors and throws specific NestJS HTTP exceptions (BadRequestException, InternalServerErrorException) based on the Vonage response status or SDK errors.
Controller Layer: The controller catches errors from the service and re-throws them. This allows NestJS's global exception filter to handle the final HTTP response formatting, ensuring consistency.
Global Exception Filter (Optional): For more complex scenarios, you could implement a custom global exception filter in NestJS to standardize error responses across your entire application.
Logging:
NestJS Logger: We are using the built-in Logger. It provides levels (log, error, warn, debug, verbose).
Context: The Logger instance is created with the class name (VonageService.name, OtpController.name) providing context in logs.
Key Information: We log initiation of actions, successful outcomes (including request IDs and status), warnings for non-critical issues (like failed verification attempts with error text), and detailed errors with stack traces.
Production Logging: In production, configure your logger to output structured JSON for easier parsing by log aggregation tools (like Datadog, Splunk, ELK stack). You might use libraries like pino with nestjs-pino.
Retry Mechanisms (Vonage Internal):
The Vonage Verify API handles retries for delivering the OTP code according to the chosen workflow (e.g., SMS -> Voice).
Your application typically doesn't need to implement server-side retries for the same verification request (requestId). If verification fails, the user usually needs to request a new code (triggering /otp/send again).
Testing Error Scenarios:
Invalid Phone Number: Send a request to /otp/send with an incorrectly formatted number (e.g., 12345). Expect a 400 Bad Request due to DTO validation.
Invalid API Keys: Temporarily change VONAGE_API_KEY or VONAGE_API_SECRET in .env and restart. Calls to Vonage should fail, likely resulting in a 500 Internal Server Error from the service.
Incorrect OTP Code: Send a request to /otp/verify with a valid requestId but an incorrect code. Expect a 400 Bad Request with a message like "The code provided does not match...".
Expired Request ID: Wait for the Vonage request to expire (default 5 minutes) and then try to verify the code. Expect a 400 Bad Request indicating the request expired.
Log Analysis:
Trace a user's OTP attempt by searching logs for the specific requestId.
Filter logs by ERROR or WARN level to quickly identify problems.
Correlate timestamps between client requests and server logs.
Database Integration for User Verification (Optional)
For this basic OTP implementation using Vonage Verify, a database is often not strictly required on the server-side to manage the OTP state itself. Vonage manages the request lifecycle using the requestId.
When You Would Need a Database:
Linking OTP to Users: If you need to associate the successful OTP verification with a specific user account in your system (e.g., marking a user as phone-verified, completing a login).
Storing Verification Status: Persisting the fact that a user successfully verified their number at a certain time.
Rate Limiting (Custom): Implementing more complex rate limiting based on user ID or phone number across multiple requests.
Audit Trails: Storing a history of verification attempts per user.
If a Database is Needed (Example using Prisma):
Install Prisma:
Bash
npm install prisma --save-dev
npm install @prisma/client
npx prisma init --datasource-provider postgresql # Or your preferred DB
Define Schema: Update prisma/schema.prisma.
Prisma
// prisma/schema.prisma
datasource db {
provider = ""postgresql""
url = env(""DATABASE_URL"")
}
generator client {
provider = ""prisma-client-js""
}
model User {
id String @id @default(cuid())
email String @unique
phoneNumber String? @unique // Store user's primary phone
phoneVerified Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// ... other user fields
OtpVerificationAttempt OtpVerificationAttempt[] // Relation to attempts
}
// Optional: Model to track verification attempts
model OtpVerificationAttempt {
id String @id @default(cuid())
requestId String @unique // Vonage request ID
phoneNumber String
status String // e.g., 'PENDING', 'VERIFIED', 'FAILED', 'EXPIRED'
userId String? // Link to user if known during request
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Set DATABASE_URL: Add your database connection string to the .env file.
Run Migrations:
Bash
npx prisma migrate dev --name init
Integrate PrismaClient: Create a PrismaService and use it in your OtpService or a dedicated UserService to update user records upon successful verification.
Before calling vonageService.sendOtp, you might create an OtpVerificationAttempt record with status: 'PENDING'.
After successful vonageService.verifyOtp, update the corresponding OtpVerificationAttempt to status: 'VERIFIED' and potentially update the linked User record (phoneVerified: true).
State Management without Database: The crucial piece of state is the requestId. The client application receives this from the /otp/send response and must send it back with the code in the /otp/verify request. The state is temporarily held by the client and validated by Vonage.
Implementing Security Features and Rate Limiting
Security is paramount for authentication mechanisms.
Input Validation and Sanitization:
DTOs with class-validator: Already implemented (Section 3, Step 2). This prevents invalid data types, unexpected fields (whitelist, forbidNonWhitelisted), and enforces formats (like IsPhoneNumber, Length). This is the primary defense against injection-like attacks on the input data structure.
Sanitization:class-validator doesn't automatically sanitize against XSS if you were reflecting input back, but for this API (which primarily deals with phone numbers, IDs, and codes passed to another service), the validation is the key.
Common Vulnerabilities:
Insecure Direct Object References (IDOR): Not directly applicable here as the requestId is generated by Vonage and acts as a temporary, single-use capability token. Ensure requestId isn't easily guessable (Vonage handles this).
Authentication Bypass: The core purpose of this implementation is to prevent bypass. Ensure the verification check (/otp/verify) is robustly linked to the action it protects (e.g., login, password reset). Don't allow the protected action to proceed if verification fails.
Rate Limiting and Brute Force Protection:
Vonage Limits: Vonage applies its own rate limits on verification requests per phone number and API key. Refer to their documentation for specifics.
Application-Level Rate Limiting: Protect your endpoints (/otp/send, /otp/verify) from excessive requests.
Install a rate limiter module: npm install --save nestjs-rate-limiter
Adjust points and duration based on expected usage and security posture. You might apply stricter limits specifically to the /otp/verify endpoint using method decorators if needed.
Verification Attempt Limit: Vonage automatically handles limits on guessing the code for a specific requestId.
Testing for Security Vulnerabilities:
Input Fuzzing: Use tools to send malformed or unexpected data to your endpoints to ensure validation holds.
Rate Limit Testing: Send rapid requests to trigger the rate limiter and verify it blocks requests correctly.
Dependency Scanning: Use npm audit or tools like Snyk to check for known vulnerabilities in dependencies.
Security Implications of Configuration:
API Key Security: Leaked API keys allow unauthorized use of your Vonage account, potentially incurring costs and enabling malicious activity. Secure storage is critical.
Rate Limiter Settings: Too permissive limits can enable brute-force or denial-of-service attacks. Too strict limits can negatively impact user experience.
Handling Phone Number Formatting and Edge Cases
Phone Number Formatting: Vonage strongly prefers the E.164 format (e.g., +14155552671). The IsPhoneNumber validator helps enforce this on input. Ensure numbers stored or processed internally consistently use this format.
International Numbers: The E.164 format supports international numbers. Test your implementation with various country codes to ensure proper formatting and delivery.
Expired Verification Requests: Vonage verification requests expire after 5 minutes by default. Handle expired requests gracefully by prompting users to request a new code.
Rate Limiting Edge Cases: During high-traffic periods, be prepared to handle rate limit responses from both your application-level limiter and Vonage's API limits.
Network Failures: Implement proper error handling for network timeouts and connection failures when calling the Vonage API. Consider displaying user-friendly error messages.
Multiple Devices: If users attempt verification from multiple devices simultaneously, ensure your application handles concurrent requests appropriately.
Conclusion
You've successfully built a secure two-factor authentication system using Vonage Verify API and NestJS. This implementation provides:
Secure authentication endpoints with proper input validation and error handling
Industry-standard security practices including rate limiting and credential management
Production-ready code with comprehensive logging and monitoring
Scalable architecture using NestJS's modular design patterns
Next Steps:
Production Deployment: Configure environment variables on your hosting platform and ensure proper secret management.
Monitoring: Set up logging aggregation to track verification success rates and identify issues.
User Experience: Implement retry mechanisms and clear error messages for common failure scenarios.
Advanced Features: Consider adding multi-channel verification (SMS + Voice) or integrating with your user authentication flow.
Testing: Write unit and integration tests for your OTP service and controller endpoints.
For production applications, review the NIST SP 800-63B-4 guidelines mentioned earlier and consider implementing additional security measures based on your application's risk profile.
Sources: Vonage Verify API documentation; NestJS official documentation; NIST Special Publication 800-63B-4.
Frequently asked questions
Install necessary dependencies like the Vonage Server SDK, NestJS ConfigModule, and class-validator. Set up environment variables for your Vonage API key and secret. Create a Vonage service to interact with the Verify API, a controller to expose API endpoints, and DTOs for request validation. Implement API endpoints for sending and verifying OTPs. Thoroughly test endpoint functionality with tools like curl and consider implementing a persistence layer to track verification attempts if required by your specific use case.
The Vonage Verify API is a service that simplifies the process of sending one-time passwords (OTPs) across various channels like SMS and voice. It manages the verification lifecycle, from sending the initial code to checking its validity, allowing developers to easily integrate 2FA into their applications. The API handles complexities such as delivery retries and various workflows for robust delivery.
NestJS provides a structured and efficient framework for building server-side applications with features like dependency injection and validation pipes. This simplifies the development process, improves code maintainability, and makes it easier to integrate external services like the Vonage Verify API. NestJS also offers a robust module system for organizing the OTP logic within the application.
Use the Vonage Verify API's verify.start() method, providing the user's phone number and your brand name. This API call triggers an SMS message containing a verification code sent to the specified phone number. The API returns a request_id that must be stored and used for the subsequent verification step.
Call the Vonage Verify API's verify.check() method with the request_id and the user-submitted OTP code. The Vonage service checks the code against the request details. The response will contain a status to indicate verification success, failure, or errors, such as an invalid code or an expired request.
The Vonage brand name, configurable in your Vonage dashboard or .env file, is the name displayed to users when they receive an OTP via SMS. It helps users identify the legitimate source of the message and enhances trust, preventing confusion with potential phishing attempts. The brand name should clearly represent your application (e.g. 'MyApp').
Implement try...catch blocks in your Vonage service and controller. Log detailed error messages, including Vonage response status and error text. Throw specific NestJS HTTP exceptions like BadRequestException and InternalServerErrorException. Consider a custom global exception filter to standardize error responses and use the built-in Logger for detailed tracing.
A database isn't always essential for basic OTP with Vonage. You would need one if you're linking successful verification to users, tracking verification status, implementing complex rate limiting, or maintaining audit trails. Vonage handles the OTP lifecycle state; use a database for features requiring persistent data or relations to your app's users or data.
While OTP enhances security, be mindful of potential vulnerabilities. These could include insecure handling of API keys and weak rate-limiting, which may lead to brute-force attacks. Ensure your application's integration with Vonage and any related data handling adheres to security best practices and secure configuration guidelines.
Yes, you can optionally specify workflow settings, particularly within the Vonage dashboard configuration. Workflows determine how the OTP is delivered and how retry attempts are managed if the initial delivery fails. Refer to the Vonage documentation for available parameters and settings to fine-tune the verification process.
Use application-level rate limiting with libraries like nestjs-rate-limiter to restrict requests per IP or user. Configure suitable rate limits for your /otp/send and /otp/verify endpoints. Vonage also implements its own rate limits, providing additional protection against attempts to guess codes or flood the service with requests.
Log the initiation of send and verify requests, successful outcomes (request IDs, statuses), warnings for non-critical issues (failed verification attempts, error details), and errors with stack traces. Contextualize logs with the class name for easier analysis. Consider using a logging library that outputs JSON for production use.
This comprehensive guide walks you through implementing two-factor authentication (2FA) and One-Time Password (OTP) verification in NestJS using the Vonage Verify API. You'll build secure, production-ready API endpoints for sending verification codes via SMS and validating user-submitted codes.
Adding two-factor authentication significantly enhances application security by requiring users to provide something they have (access to their phone) in addition to something they know (like a password), mitigating risks associated with compromised credentials.
What You'll Build: Secure Two-Factor Authentication in NestJS
What We'll Build:
Problem Solved: Implementing a reliable and secure second factor of authentication for user actions like login, password reset, or sensitive operations.
Technologies Used:
@vonage/server-sdk.System Architecture:
Prerequisites:
Before you begin, ensure you have the following:
Node.js (v20 or v22 recommended) and npm/yarn installed. Note: Node.js v16 reached end-of-life on September 11, 2023, and v18 will reach EOL on April 30, 2025. For production applications, use Node.js v20 "Iron" (Maintenance LTS until April 2026) or v22 "Jod" (Active LTS until October 2025, Maintenance until April 2027).
A Vonage API account. You can sign up for free credits.
Your Vonage API Key and API Secret (found on your Vonage dashboard).
NestJS CLI installed (
npm install -g @nestjs/cli).Basic understanding of TypeScript and REST APIs.
Sources: Node.js Release Schedule; Node.js v16 EOL announcement (September 11, 2023); Node.js v18 EOL date (April 30, 2025).
Setting Up Your NestJS Project for Vonage Integration
Let's start by creating a new NestJS project and installing the necessary dependencies.
Create a new NestJS Project: Open your terminal and run:
nest new vonage-otp-nestjs cd vonage-otp-nestjsChoose your preferred package manager (npm or yarn) when prompted.
Install Dependencies: We need the Vonage Server SDK, NestJS config module for environment variables, and class-validator/transformer for input validation.
# Using npm npm install @vonage/server-sdk @nestjs/config class-validator class-transformer # Or using yarn yarn add @vonage/server-sdk @nestjs/config class-validator class-transformerSet up Environment Variables: Create a
.envfile in the root of your project. This file will store your sensitive Vonage credentials. Never commit this file to version control. Add a.gitignoreentry for.envif it's not already there..env# Vonage API Credentials VONAGE_API_KEY=YOUR_VONAGE_API_KEY VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET VONAGE_BRAND_NAME=MyApp # Optional: Brand name shown in SMS messageReplace
YOUR_VONAGE_API_KEYandYOUR_VONAGE_API_SECRETwith your actual credentials from the Vonage Dashboard.Configure NestJS ConfigModule: Import and configure the
ConfigModulein your main application module (src/app.module.ts) to load environment variables from the.envfile.src/app.module.tsimport { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { OtpModule } from './otp/otp.module'; // We will create this next @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, // Make ConfigModule available globally envFilePath: '.env', // Specify the env file path }), OtpModule, // Import the OTP module ], controllers: [AppController], providers: [AppService], }) export class AppModule {}Using
isGlobal: truemakes theConfigServiceavailable throughout your application without needing to importConfigModuleinto every feature module.Enable Validation Pipe Globally: In
src/main.ts, enable the built-inValidationPipeto automatically validate incoming request payloads based on DTOs (Data Transfer Objects).src/main.tsimport { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { ValidationPipe, Logger } from '@nestjs/common'; // Import Logger import { ConfigService } from '@nestjs/config'; // Import ConfigService async function bootstrap() { const app = await NestFactory.create(AppModule); const logger = new Logger('Bootstrap'); // Create a logger instance // Get ConfigService to read PORT potentially const configService = app.get(ConfigService); const port = configService.get<number>('PORT') || 3000; // Use PORT from .env or default to 3000 // Enable global validation pipe app.useGlobalPipes( new ValidationPipe({ whitelist: true, // Strip properties not defined in DTO transform: true, // Automatically transform payloads to DTO instances forbidNonWhitelisted: true, // Throw error if non-whitelisted properties are provided }), ); await app.listen(port); logger.log(`Application listening on port ${port}`); // Log the port } bootstrap();The options
whitelistandforbidNonWhitelistedenhance security by ensuring only expected data fields are processed.transformautomatically converts incoming JSON into typed DTO instances.Implementing the Vonage Verify API Service
We'll encapsulate the Vonage SDK interactions within a dedicated NestJS service.
Generate the OTP Module and Service: Use the NestJS CLI to generate a module and service for OTP functionality.
nest generate module otp nest generate service otp/vonage --flat # Create VonageService inside otp folderThe
--flatflag prevents the CLI from creating an extra sub-directory for the service.Implement the
VonageService: This service will handle initializing the Vonage client and calling the Verify API methods.src/otp/vonage.service.tsimport { Injectable, Logger, InternalServerErrorException, BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Vonage } from '@vonage/server-sdk'; import { VerifyRequestResponse, VerifyCheckResponse } from '@vonage/verify'; // Import response types @Injectable() export class VonageService { private readonly logger = new Logger(VonageService.name); private vonage: Vonage; private brandName: string; constructor(private configService: ConfigService) { const apiKey = this.configService.get<string>('VONAGE_API_KEY'); const apiSecret = this.configService.get<string>('VONAGE_API_SECRET'); this.brandName = this.configService.get<string>('VONAGE_BRAND_NAME', 'MyApp'); // Default brand name if (!apiKey || !apiSecret) { this.logger.error('Vonage API Key or Secret not found in environment variables.'); throw new InternalServerErrorException('Vonage API credentials are missing.'); } this.vonage = new Vonage({ apiKey, apiSecret }); this.logger.log('Vonage client initialized successfully.'); } /** * Sends an OTP verification code to the specified phone number. * @param phoneNumber The E.164 formatted phone number. * @returns The request ID for the verification attempt. * @throws InternalServerErrorException on API call failure. * @throws BadRequestException on Vonage API error (e.g., invalid number). */ async sendOtp(phoneNumber: string): Promise<{ request_id: string }> { this.logger.log(`Sending OTP request to phone number: ${phoneNumber}`); try { const result: VerifyRequestResponse = await this.vonage.verify.start({ number: phoneNumber, brand: this.brandName, // workflow_id: 6, // Optional: Specify workflow (e.g., 6 for 6-digit code) // code_length: 6, // Optional: Specify code length (4 or 6) }); // Log essential response details this.logger.log( `Vonage verify request response: Status=${result.status}, RequestID=${result.request_id}` + (result.error_text ? `, Error=${result.error_text}` : '') ); // Check Vonage API status if (result.status !== '0') { this.logger.error(`Vonage API Error: ${result.error_text} (Status: ${result.status})`); // Map specific errors if needed, otherwise throw a generic bad request throw new BadRequestException(`Failed to send OTP: ${result.error_text || 'Invalid request'}`); } this.logger.log(`OTP request sent successfully. Request ID: ${result.request_id}`); return { request_id: result.request_id }; } catch (error) { this.logger.error('Error calling Vonage verify API', error.stack); // Handle specific SDK errors or re-throw generic errors if (error instanceof BadRequestException) { throw error; // Re-throw known bad requests } throw new InternalServerErrorException('Failed to send OTP due to an internal error.'); } } /** * Verifies the OTP code submitted by the user. * @param requestId The request ID from the sendOtp call. * @param code The OTP code entered by the user. * @returns True if verification is successful. * @throws BadRequestException if the code is invalid or the request expired. * @throws InternalServerErrorException on API call failure. */ async verifyOtp(requestId: string, code: string): Promise<boolean> { this.logger.log(`Verifying OTP for Request ID: ${requestId} with code: ${code}`); try { const result: VerifyCheckResponse = await this.vonage.verify.check(requestId, code); // Log essential response details this.logger.log( `Vonage verify check response: Status=${result.status}, RequestID=${requestId}` + (result.error_text ? `, Error=${result.error_text}` : '') ); // Status '0' means successful verification if (result.status === '0') { this.logger.log(`OTP verification successful for Request ID: ${requestId}`); return true; } else { // Handle specific error statuses from Vonage // See: https://developer.vonage.com/api/verify#check-errors this.logger.warn(`OTP verification failed for Request ID: ${requestId}. Status: ${result.status}, Error: ${result.error_text}`); throw new BadRequestException(`OTP verification failed: ${result.error_text || 'Invalid code or request expired.'}`); } } catch (error) { this.logger.error(`Error calling Vonage check API for Request ID: ${requestId}`, error.stack); // Re-throw known bad requests or throw a generic internal error if (error instanceof BadRequestException) { throw error; } throw new InternalServerErrorException('Failed to verify OTP due to an internal error.'); } } }Injecting
ConfigServiceallows secure access to API keys from environment variables rather than hardcoding them. Using theLoggeris essential for debugging and monitoring API interactions. Checking the API key/secret in the constructor ensures a fast failure if essential configuration is missing. Therequest_idis returned because the client needs this ID for the subsequent verification request.Update the
OtpModule: Make sureVonageServiceis provided and exported by theOtpModule.src/otp/otp.module.tsimport { Module } from '@nestjs/common'; import { VonageService } from './vonage.service'; import { OtpController } from './otp.controller'; // We will create this next @Module({ providers: [VonageService], exports: [VonageService], // Export if needed by other modules controllers: [OtpController], // Add the controller }) export class OtpModule {}Building REST API Endpoints for OTP Verification
Now, let's create the controller that exposes the OTP functionality via REST endpoints and the DTOs for request validation.
Generate the OTP Controller:
Create Data Transfer Objects (DTOs): Create DTO files to define the expected shape and validation rules for incoming request bodies.
src/otp/dto/send-otp.dto.tsimport { IsNotEmpty, IsString, IsPhoneNumber } from 'class-validator'; export class SendOtpDto { @IsNotEmpty() @IsString() @IsPhoneNumber(null, { message: 'Phone number must be a valid E.164 format (e.g., +14155552671)' }) // Use null for region for global numbers phone: string; }Using
IsPhoneNumberensures the input is a valid phone number format (E.164 is recommended for Vonage).src/otp/dto/verify-otp.dto.tsimport { IsNotEmpty, IsString, Length } from 'class-validator'; export class VerifyOtpDto { @IsNotEmpty() @IsString() // Add validation based on your request_id format if known, otherwise basic checks suffice requestId: string; @IsNotEmpty() @IsString() @Length(4, 6, { message: 'OTP code must be between 4 and 6 digits' }) // Adjust length (4 or 6) to match the 'code_length' used in sendOtp (Vonage API default is 4 if unspecified) code: string; }Using
Lengthensures the code matches the expected format. Vonage codes are typically 4 or 6 digits; match this to your configuration or the default (4).Implement the
OtpController: Inject theVonageServiceand define the API endpoints using decorators.src/otp/otp.controller.tsimport { Controller, Post, Body, HttpCode, HttpStatus, Logger, BadRequestException } from '@nestjs/common'; import { VonageService } from './vonage.service'; import { SendOtpDto } from './dto/send-otp.dto'; import { VerifyOtpDto } from './dto/verify-otp.dto'; @Controller('otp') // Route prefix: /otp export class OtpController { private readonly logger = new Logger(OtpController.name); constructor(private readonly vonageService: VonageService) {} /** * Endpoint to request an OTP code to be sent to a phone number. */ @Post('/send') // Full route: POST /otp/send @HttpCode(HttpStatus.OK) // Send 200 OK on success instead of 201 Created async requestOtp(@Body() sendOtpDto: SendOtpDto): Promise<{ requestId: string; message: string }> { this.logger.log(`Received request to send OTP to: ${sendOtpDto.phone}`); try { const result = await this.vonageService.sendOtp(sendOtpDto.phone); return { requestId: result.request_id, message: 'OTP request sent successfully. Please check your phone.', }; } catch (error) { // Log and re-throw errors handled by the service or global exception filters this.logger.error(`Error in requestOtp: ${error.message}`, error.stack); throw error; // Let NestJS handle the standardized error response } } /** * Endpoint to verify an OTP code. */ @Post('/verify') // Full route: POST /otp/verify @HttpCode(HttpStatus.OK) async verifyOtp(@Body() verifyOtpDto: VerifyOtpDto): Promise<{ verified: boolean; message: string }> { this.logger.log(`Received request to verify OTP for request ID: ${verifyOtpDto.requestId}`); try { // The VonageService.verifyOtp method throws BadRequestException on failure, // so we only need to handle the success case here. await this.vonageService.verifyOtp( verifyOtpDto.requestId, verifyOtpDto.code, ); // If the above line doesn't throw, verification was successful. return { verified: true, message: 'OTP verified successfully.' }; } catch (error) { // Log errors thrown by the service (like BadRequestException for failed verification) // and re-throw them for consistent global error handling. this.logger.error(`Error in verifyOtp: ${error.message}`, error.stack); throw error; } } }The
@Body()decorator extracts and automatically validates the request body using the provided DTO and the globalValidationPipe. UsingHttpCode(HttpStatus.OK)is often more appropriate than the default 201 Created for POST requests that don't create a new resource.Testing Endpoints with
curl:Start the Application:
Send OTP Request: Replace
+14155551234with a real phone number you have access to (in E.164 format).curl -X POST http://localhost:3000/otp/send \ -H 'Content-Type: application/json' \ -d '{ ""phone"": ""+14155551234"" }'Expected JSON Response:
{ ""requestId"": ""SOME_REQUEST_ID_FROM_VONAGE"", ""message"": ""OTP request sent successfully. Please check your phone."" }You should receive an SMS with a code on the provided number. Note down the
requestId.Verify OTP Code: Replace
SOME_REQUEST_ID_FROM_VONAGEwith the actual ID received and1234with the code from the SMS.curl -X POST http://localhost:3000/otp/verify \ -H 'Content-Type: application/json' \ -d '{ ""requestId"": ""SOME_REQUEST_ID_FROM_VONAGE"", ""code"": ""1234"" }'Expected JSON Response (Success):
{ ""verified"": true, ""message"": ""OTP verified successfully."" }Expected JSON Response (Failure - e.g., wrong code):
{ ""statusCode"": 400, ""message"": ""OTP verification failed: The code provided does not match the expected value."", ""error"": ""Bad Request"" }Configuring Vonage API Credentials and Settings
Let's ensure the Vonage integration is correctly configured.
Obtaining API Credentials:
Environment Variables: As set up in Section 1, Step 3:
.env# Vonage API Credentials VONAGE_API_KEY=YOUR_VONAGE_API_KEY # Purpose: Authenticates your application with the Vonage API. Format: Alphanumeric string. Obtain: Vonage Dashboard homepage. VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET # Purpose: Secret key paired with the API Key for authentication. Format: Alphanumeric string. Obtain: Vonage Dashboard homepage. VONAGE_BRAND_NAME=MyApp # Purpose: Displayed as the sender in the OTP message. Format: String (up to 11 alphanumeric chars or 16 numeric chars). Obtain: Define yourself. Optional.Secure Handling:
.envFile: Keep API keys in the.envfile..gitignore: Ensure.envis listed in your.gitignorefile to prevent accidental commits.Fallback Mechanisms: The Vonage Verify API itself has built-in fallbacks (e.g., SMS followed by Voice call if configured or default). For application-level resilience:
VonageServiceincludestry...catchblocks. If a call tosendOtporverifyOtpfails due to network issues or Vonage downtime, theInternalServerErrorExceptionwill be thrown.Error Handling and Logging Best Practices
We've already incorporated basic logging and error handling. Let's refine it.
Consistent Error Handling:
VonageServicecatches errors during SDK calls. It logs detailed errors and throws specific NestJS HTTP exceptions (BadRequestException,InternalServerErrorException) based on the Vonage response status or SDK errors.Logging:
Logger. It provides levels (log,error,warn,debug,verbose).Loggerinstance is created with the class name (VonageService.name,OtpController.name) providing context in logs.pinowithnestjs-pino.Retry Mechanisms (Vonage Internal):
requestId). If verification fails, the user usually needs to request a new code (triggering/otp/sendagain).Testing Error Scenarios:
/otp/sendwith an incorrectly formatted number (e.g.,12345). Expect a 400 Bad Request due to DTO validation.VONAGE_API_KEYorVONAGE_API_SECRETin.envand restart. Calls to Vonage should fail, likely resulting in a 500 Internal Server Error from the service./otp/verifywith a validrequestIdbut an incorrectcode. Expect a 400 Bad Request with a message like "The code provided does not match...".Log Analysis:
requestId.ERRORorWARNlevel to quickly identify problems.Database Integration for User Verification (Optional)
For this basic OTP implementation using Vonage Verify, a database is often not strictly required on the server-side to manage the OTP state itself. Vonage manages the request lifecycle using the
requestId.When You Would Need a Database:
If a Database is Needed (Example using Prisma):
Install Prisma:
npm install prisma --save-dev npm install @prisma/client npx prisma init --datasource-provider postgresql # Or your preferred DBDefine Schema: Update
prisma/schema.prisma.Set
DATABASE_URL: Add your database connection string to the.envfile.Run Migrations:
Integrate PrismaClient: Create a
PrismaServiceand use it in yourOtpServiceor a dedicatedUserServiceto update user records upon successful verification.vonageService.sendOtp, you might create anOtpVerificationAttemptrecord withstatus: 'PENDING'.vonageService.verifyOtp, update the correspondingOtpVerificationAttempttostatus: 'VERIFIED'and potentially update the linkedUserrecord (phoneVerified: true).State Management without Database: The crucial piece of state is the
requestId. The client application receives this from the/otp/sendresponse and must send it back with the code in the/otp/verifyrequest. The state is temporarily held by the client and validated by Vonage.Implementing Security Features and Rate Limiting
Security is paramount for authentication mechanisms.
Input Validation and Sanitization:
class-validator: Already implemented (Section 3, Step 2). This prevents invalid data types, unexpected fields (whitelist,forbidNonWhitelisted), and enforces formats (likeIsPhoneNumber,Length). This is the primary defense against injection-like attacks on the input data structure.class-validatordoesn't automatically sanitize against XSS if you were reflecting input back, but for this API (which primarily deals with phone numbers, IDs, and codes passed to another service), the validation is the key.Common Vulnerabilities:
requestIdis generated by Vonage and acts as a temporary, single-use capability token. EnsurerequestIdisn't easily guessable (Vonage handles this)./otp/verify) is robustly linked to the action it protects (e.g., login, password reset). Don't allow the protected action to proceed if verification fails.Rate Limiting and Brute Force Protection:
/otp/send,/otp/verify) from excessive requests.Install a rate limiter module:
npm install --save nestjs-rate-limiterConfigure it in
app.module.ts:import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { OtpModule } from './otp/otp.module'; import { RateLimiterModule, RateLimiterGuard } from 'nestjs-rate-limiter'; import { APP_GUARD } from '@nestjs/core'; // Import APP_GUARD @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }), OtpModule, RateLimiterModule.register({ points: 10, // Max 10 requests... duration: 60, // ...per 60 seconds per IP // You can customize key generation (e.g., by user ID if authenticated) }), ], controllers: [AppController], providers: [ AppService, // Apply rate limiting globally { provide: APP_GUARD, useClass: RateLimiterGuard, }, ], }) export class AppModule {}Adjust
pointsanddurationbased on expected usage and security posture. You might apply stricter limits specifically to the/otp/verifyendpoint using method decorators if needed.requestId.Testing for Security Vulnerabilities:
npm auditor tools like Snyk to check for known vulnerabilities in dependencies.Security Implications of Configuration:
Handling Phone Number Formatting and Edge Cases
+14155552671). TheIsPhoneNumbervalidator helps enforce this on input. Ensure numbers stored or processed internally consistently use this format.Conclusion
You've successfully built a secure two-factor authentication system using Vonage Verify API and NestJS. This implementation provides:
Next Steps:
For production applications, review the NIST SP 800-63B-4 guidelines mentioned earlier and consider implementing additional security measures based on your application's risk profile.
Sources: Vonage Verify API documentation; NestJS official documentation; NIST Special Publication 800-63B-4.