Build OTP Two-Factor Authentication with Node.js, NestJS, and Infobip
Complete guide to implementing secure One-Time Password (OTP) authentication in NestJS using the Infobip 2FA API. Learn how to send SMS verification codes and verify user submissions.
Build a secure One-Time Password (OTP) system for Two-Factor Authentication (2FA) in your Node.js application using NestJS and the Infobip 2FA API. This guide covers everything from initial setup to production deployment.
You'll create a robust, scalable OTP service that integrates seamlessly into user authentication flows – registration, login, or sensitive action confirmation. By the end, you'll have a functional NestJS API that sends OTPs via SMS and verifies user-submitted codes, backed by Infobip's global communication infrastructure.
Project Overview and Goals
What You'll Build:
A NestJS-based microservice or module that:
Generates and sends OTP SMS messages to user-provided phone numbers via the Infobip API.
Verifies OTP codes submitted by users against the codes generated by Infobip.
Problem Solved:
Enhance your application security by adding a second authentication factor. Prevent unauthorized access even if passwords are compromised by verifying possession of a trusted device (the user's phone).
Technologies Used:
Node.js: The runtime environment.
NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications using TypeScript. Chosen for its modular architecture, dependency injection, and built-in support for features like validation and configuration management.
TypeScript: Adds static typing to JavaScript, improving code quality and maintainability.
Infobip 2FA API: A specialized API from Infobip for handling OTP workflows (application/template setup, PIN generation, sending, verification). Chosen as per the requirement.
Axios: A promise-based HTTP client for making requests to the Infobip API (@nestjs/axios wrapper).
dotenv: For managing environment variables securely (@nestjs/config wrapper).
class-validator & class-transformer: For easy request payload validation using decorators.
@nestjs/throttler: For implementing rate limiting to prevent abuse.
System Architecture:
The interaction flow is as follows:
User initiates an action requiring OTP (e.g., Login) in the Client Application (Web/Mobile).
Client Application sends a POST request to the NestJS OTP API endpoint (e.g., /otp/send) containing the user's phone number.
The NestJS API sends a request to the Infobip 2FA API to send a PIN, including the Infobip Application ID, Message ID, and the user's phone number.
The Infobip API responds to the NestJS API with a unique pinId. The NestJS application might temporarily store this pinId linked to the user's session or action.
The Infobip API sends an SMS message containing the OTP code to the user's phone.
The user receives the SMS and enters the OTP code into the Client Application.
The Client Application sends a POST request to the NestJS OTP API endpoint (e.g., /otp/verify) containing the pinId received earlier and the OTP code entered by the user.
The NestJS API sends a request to the Infobip 2FA API to verify the PIN, providing the pinId and the submitted OTP code.
The Infobip API responds to the NestJS API indicating whether the verification was successful (verified: true/false).
The NestJS API relays the verification result back to the Client Application.
Based on the result, the Client Application either grants access to the user or displays an appropriate error message.
Prerequisites:
Node.js (LTS version recommended, v18 or v20) and npm or yarn.
An Infobip account (free trial available).
Basic understanding of TypeScript and NestJS (modules, controllers, services).
A code editor (VS Code recommended).
API testing tools (Postman or curl).
Setting Up Your Project
Bootstrap a new NestJS project and install the necessary dependencies.
Step 1: Create a New NestJS Project
Open your terminal and run the NestJS CLI command:
Bash
npx @nestjs/cli new nestjs-infobip-otp
cd nestjs-infobip-otp
Choose your preferred package manager (npm or yarn) when prompted.
Step 2: Install Dependencies
We need modules for configuration, making HTTP requests, validation, and rate limiting.
Create a .env file in your project root. This file stores sensitive information like API keys and configuration IDs. Never commit this file to version control.
.env
# .env
# Infobip Configuration
INFOBIP_BASE_URL=YOUR_INFOBIP_API_BASE_URL # e.g., https://xyz123.api.infobip.com
INFOBIP_API_KEY=YOUR_INFOBIP_API_KEY
INFOBIP_APP_ID=YOUR_INFOBIP_2FA_APPLICATION_ID
INFOBIP_MESSAGE_ID=YOUR_INFOBIP_2FA_MESSAGE_ID
# Rate Limiting (Optional but Recommended)
THROTTLE_TTL=60 # Time-to-live in seconds (e.g., 60 seconds)
THROTTLE_LIMIT=10 # Max requests per TTL (e.g., 10 requests)
Retrieve Your Infobip Credentials:
INFOBIP_BASE_URL & INFOBIP_API_KEY:
Log in to your Infobip account.
Find your Base URL on the dashboard homepage or in the API documentation section. It's specific to your account.
Navigate to API Keys management (typically under account settings or developer tools).
Create a new API Key with a descriptive name (e.g., "NestJS OTP Service").
Copy the generated API Key and Base URL into your .env file. Treat the API Key like a password.
INFOBIP_APP_ID & INFOBIP_MESSAGE_ID:
Create these via the Infobip API before sending OTPs. You'll do this once during setup.
Create 2FA Application: Send a POST request to {INFOBIP_BASE_URL}/2fa/1/applications.
The response will contain an applicationId. Copy this value into INFOBIP_APP_ID in your .env file.
Create Message Template: Use curl or Postman to send a POST request to {INFOBIP_BASE_URL}/2fa/1/applications/{INFOBIP_APP_ID}/messages. Replace {INFOBIP_APP_ID} with the ID you just received.
Note: You might need to register senderIds depending on the country and regulations. InfoSMS is often a default shared sender. Check Infobip documentation for details.
The response will contain a messageId. Copy this value into INFOBIP_MESSAGE_ID in your .env file.
Step 4: Load Environment Variables Using ConfigModule
Update src/app.module.ts to load and validate your environment variables.
# .gitignore (ensure these lines exist)
/dist
/node_modules
.env
Create a .env.example file listing required variables (without values) and commit it to your repository. This helps collaborators set up their environment.
Implementing Core Functionality
Create a dedicated module (OtpModule) containing a service (OtpService) to handle Infobip API interactions.
Step 1: Generate the OTP Module and Service
Use the NestJS CLI:
Bash
nest g module otp
nest g service otp --no-spec # --no-spec skips generating a test file for now
Step 2: Configure HttpModule for Axios
Make the HttpModule available within your OtpModule to inject HttpService.
TypeScript
// src/otp/otp.module.tsimport { Module } from'@nestjs/common';
import { HttpModule } from'@nestjs/axios';
import { OtpService } from'./otp.service';
// Import OtpController when you create it// import { OtpController } from './otp.controller';@Module({
imports: [
// Configure HttpModule with timeout optionsHttpModule.register({
timeout: 5000, // 5 secondsmaxRedirects: 5,
}),
],
providers: [OtpService],
// controllers: [OtpController], // Uncomment laterexports: [OtpService], // Export if other modules need it
})
exportclassOtpModule {}
Step 3: Implement the OtpService
This service will contain the methods for sending and verifying OTPs.
TypeScript
// src/otp/otp.service.tsimport { Injectable, Logger } from'@nestjs/common';
import { HttpService } from'@nestjs/axios';
import { ConfigService } from'@nestjs/config';
import { firstValueFrom } from'rxjs';
import { AxiosError } from'axios';
@Injectable()
exportclassOtpService {
privatereadonly logger = newLogger(OtpService.name);
privatereadonlyinfobipBaseUrl: string;
privatereadonlyinfobipApiKey: string;
privatereadonlyinfobipAppId: string;
privatereadonlyinfobipMessageId: string;
constructor(privatereadonlyhttpService: HttpService,
privatereadonlyconfigService: ConfigService,
) {
// Load Infobip credentials from environment variablesthis.infobipBaseUrl = this.configService.get<string>('INFOBIP_BASE_URL');
this.infobipApiKey = this.configService.get<string>('INFOBIP_API_KEY');
this.infobipAppId = this.configService.get<string>('INFOBIP_APP_ID');
this.infobipMessageId = this.configService.get<string>('INFOBIP_MESSAGE_ID');
if (!this.infobipBaseUrl || !this.infobipApiKey || !this.infobipAppId || !this.infobipMessageId) {
this.logger.error('Infobip configuration is missing in environment variables.');
thrownewError('Infobip configuration is incomplete.');
}
}
/**
* Sends an OTP SMS to the specified phone number via Infobip.
* @paramphoneNumber - Recipient's phone number in E.164 format (e.g., +14155552671).
* @returns The pinId generated by Infobip, used for verification.
* @throws Error if the API call fails.
*/asyncsendOtp(phoneNumber: string): Promise<string> {
// Note: Always refer to official Infobip API documentation for current endpoints and versions.const url = `${this.infobipBaseUrl}/2fa/2/pin`;
const payload = {
applicationId: this.infobipAppId,
messageId: this.infobipMessageId,
from: 'InfoSMS', // Or your registered sender IDto: phoneNumber,
};
const headers = {
'Authorization': `App ${this.infobipApiKey}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
this.logger.log(`Sending OTP to ${phoneNumber} via Infobip`);
try {
const response = awaitfirstValueFrom(
this.httpService.post(url, payload, { headers }),
);
this.logger.log(`Infobip Send OTP Response Status: ${response.status}`);
// Validate response structureif (!response.data || !response.data.pinId) {
this.logger.error('Infobip response missing pinId', response.data);
thrownewError('Failed to send OTP: Invalid response structure from Infobip.');
}
return response.data.pinId;
} catch (error) {
this.handleInfobipError(error, 'sendOtp');
// handleInfobipError throws, so this line is for type safetythrownewError('Failed to send OTP');
}
}
/**
* Verifies an OTP code using the pinId provided by Infobip.
* @parampinId - The ID received after successfully sending the OTP.
* @paramotpCode - The OTP code entered by the user.
* @returns True if the OTP is verified successfully, false otherwise.
* @throws Error if the API call fails or returns an unexpected status.
*/asyncverifyOtp(pinId: string, otpCode: string): Promise<boolean> {
const url = `${this.infobipBaseUrl}/2fa/2/pin/${pinId}/verify`;
const payload = {
pin: otpCode,
};
const headers = {
'Authorization': `App ${this.infobipApiKey}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
this.logger.log(`Verifying OTP for pinId: ${pinId}`);
try {
const response = awaitfirstValueFrom(
this.httpService.post(url, payload, { headers }),
);
this.logger.log(`Infobip Verify OTP Response Status: ${response.status}`);
// Check the verified status in the response dataif (response.data && response.data.verified === true) {
this.logger.log(`OTP verified successfully for pinId: ${pinId}`);
returntrue;
} else {
// Log the reason if available (e.g., attempts exceeded, wrong pin)this.logger.warn(`OTP verification failed for pinId: ${pinId}. Reason: ${response.data?.ncStatus || response.data?.status || 'Unknown'}`, response.data);
returnfalse;
}
} catch (error) {
// Handle verification failures due to wrong pin or expired OTP// These might return 4xx errors from Infobipif (error instanceofAxiosError && error.response) {
this.logger.warn(`Infobip API verification error for pinId ${pinId}: ${error.response.status}`, error.response.data);
// Check specific Infobip error codes if needed// e.g., if (error.response.data.requestError?.serviceException?.messageId === 'PIN_NOT_VALID') return false;returnfalse; // Treat non-2xx as verification failure
}
// Handle other errors (network, config)this.handleInfobipError(error, 'verifyOtp');
returnfalse;
}
}
/**
* Handles errors from Axios/Infobip API calls.
*/privatehandleInfobipError(error: any, context: string): void {
if (error instanceofAxiosError) {
this.logger.error(
`Infobip API Error during ${context}: ${error.response?.status}${error.message}`,
error.response?.data || error.stack, // Log response data if available
);
// Re-throw a more specific error or a generic onethrownewError(`Infobip API request failed during ${context}. Status: ${error.response?.status}`);
} else {
this.logger.error(`Unexpected error during ${context}: ${error.message}`, error.stack);
thrownewError(`An unexpected error occurred during ${context}.`);
}
}
}
Explanation:
We inject HttpService (for making requests) and ConfigService (for environment variables).
Credentials are loaded in the constructor. Basic validation ensures they exist.
sendOtp: Constructs the URL and payload for Infobip's /2fa/2/pin endpoint, sets the Authorization header using the API key, makes the POST request, and returns the pinId from the response.
verifyOtp: Constructs the URL (/2fa/2/pin/{pinId}/verify) and payload, makes the POST request, and checks the verified field in the response. Returns true or false.
handleInfobipError: A private helper to log errors consistently, distinguishing between Axios HTTP errors and other unexpected errors. It re-throws an error to be caught higher up (e.g., in the controller or an exception filter).
Building Your API Layer
Expose the OTP functionality through a controller with specific endpoints.
Step 1: Create Data Transfer Objects (DTOs) for Validation
Create files for request body validation.
Create SendOtpDto in src/otp/dto/send-otp.dto.ts:
TypeScript
// src/otp/dto/send-otp.dto.tsimport { IsNotEmpty, IsString, IsPhoneNumber } from'class-validator';
exportclassSendOtpDto {
@IsNotEmpty()
@IsString()
// Ensure input is in E.164 format for Infobip@IsPhoneNumber(null, { message: 'Phone number must be a valid E.164 format string (e.g., +14155552671)' })
phoneNumber: string;
}
Create VerifyOtpDto in src/otp/dto/verify-otp.dto.ts:
TypeScript
// src/otp/dto/verify-otp.dto.tsimport { IsNotEmpty, IsString, Length } from'class-validator';
exportclassVerifyOtpDto {
@IsNotEmpty()
@IsString()
pinId: string; // The ID received from the /send endpoint@IsNotEmpty()
@IsString()
@Length(4, 8, { message: 'OTP code must be between 4 and 8 digits' }) // Adjust based on your pinLengthotpCode: string;
}
SendOtpDto: Requires a phoneNumber field in E.164 format (e.g., +14155552671).
VerifyOtpDto: Requires pinId (received from the send request) and otpCode (entered by user), validating length based on your message template.
Step 2: Generate the OTP Controller
Bash
nest g controller otp --no-spec
Step 3: Implement the OtpController
TypeScript
// src/otp/otp.controller.tsimport { Controller, Post, Body, ValidationPipe, UsePipes, Logger, HttpCode, HttpStatus, UseGuards } from'@nestjs/common';
import { Throttle, ThrottlerGuard } from'@nestjs/throttler';
import { OtpService } from'./otp.service';
import { SendOtpDto } from'./dto/send-otp.dto';
import { VerifyOtpDto } from'./dto/verify-otp.dto';
@Controller('otp')
exportclassOtpController {
privatereadonly logger = newLogger(OtpController.name);
constructor(privatereadonlyotpService: OtpService) {}
/**
* Endpoint to request sending an OTP SMS.
* Applies rate limiting.
*/@UseGuards(ThrottlerGuard) // Apply the global throttler guard@Throttle({ default: { limit: 3, ttl: 60000 } }) // Override: Max 3 requests per 60 seconds per user/IP@Post('send')
@HttpCode(HttpStatus.OK) // Return 200 OK on success@UsePipes(newValidationPipe({ transform: true, whitelist: true })) // Enable validationasyncsendOtp(@Body() sendOtpDto: SendOtpDto): Promise<{ pinId: string; message: string }> {
this.logger.log(`Received request to send OTP to: ${sendOtpDto.phoneNumber}`);
const pinId = awaitthis.otpService.sendOtp(sendOtpDto.phoneNumber);
this.logger.log(`OTP sent successfully, pinId: ${pinId}`);
return { pinId: pinId, message: 'OTP sent successfully. Please verify.' };
}
/**
* Endpoint to verify an OTP code.
* Applies rate limiting.
*/@UseGuards(ThrottlerGuard)
@Throttle({ default: { limit: 5, ttl: 60000 } }) // Override: Max 5 verification attempts per 60 seconds@Post('verify')
@HttpCode(HttpStatus.OK) // Return 200 OK regardless of verification success/fail@UsePipes(newValidationPipe({ transform: true, whitelist: true }))
asyncverifyOtp(@Body() verifyOtpDto: VerifyOtpDto): Promise<{ verified: boolean; message: string }> {
this.logger.log(`Received request to verify OTP for pinId: ${verifyOtpDto.pinId}`);
const isVerified = awaitthis.otpService.verifyOtp(verifyOtpDto.pinId, verifyOtpDto.otpCode);
if (isVerified) {
this.logger.log(`OTP verification successful for pinId: ${verifyOtpDto.pinId}`);
return { verified: true, message: 'OTP verified successfully.' };
} else {
this.logger.warn(`OTP verification failed for pinId: ${verifyOtpDto.pinId}`);
return { verified: false, message: 'OTP verification failed. Invalid code or expired.' };
}
}
}
Step 4: Register the Controller
Uncomment the controller in src/otp/otp.module.ts:
The controller defines two POST endpoints: /otp/send and /otp/verify.
@UsePipes(new ValidationPipe(...)) automatically validates incoming request bodies against the DTOs (SendOtpDto, VerifyOtpDto). whitelist: true strips any properties not defined in the DTO.
@UseGuards(ThrottlerGuard) applies the global rate limiting configured in AppModule.
@Throttle(...) allows overriding the global limits for specific endpoints. We allow fewer send requests than verify requests per time window.
The controller methods delegate the core logic to OtpService.
Responses provide clear messages and status (pinId on send, verified boolean on verify).
@HttpCode(HttpStatus.OK) ensures a 200 status code is returned even if verification fails (as the API call itself succeeded). The verified flag in the response body indicates the outcome.
Testing with curl:
Start the application:npm run start:dev or yarn start:dev
Send OTP:
Bash
curl --location --request POST 'http://localhost:3000/otp/send' \
--header 'Content-Type: application/json' \
--data '{
""phoneNumber"": ""+14155552671"" # Replace with a real phone number accessible to you
}'
Expected Response (Success):
JSON
{""pinId"":""SOME_PIN_ID_FROM_INFOBIP"",""message"":""OTP sent successfully. Please verify.""}
Check your phone for the SMS containing the OTP code.
Verify OTP: Use the pinId from the previous response and the code from the SMS.
Bash
curl --location --request POST 'http://localhost:3000/otp/verify' \
--header 'Content-Type: application/json' \
--data '{
""pinId"": ""SOME_PIN_ID_FROM_INFOBIP"", # Use the actual pinId received
""otpCode"": ""123456"" # Use the actual code from the SMS
}'
Find your unique API Base URL and create an API Key.
Store these in your .env file as INFOBIP_BASE_URL and INFOBIP_API_KEY.
Create 2FA Application:
Use the Infobip API (POST /2fa/1/applications) to create a 2FA application.
Configure settings like pinAttempts, pinTimeToLive, etc.
Store the returned applicationId in .env as INFOBIP_APP_ID.
Create 2FA Message Template:
Use the Infobip API (POST /2fa/1/applications/{APP_ID}/messages) to create a message template linked to your application.
Define messageText (including {{pin}}), pinType, pinLength, and optionally senderId.
Store the returned messageId in .env as INFOBIP_MESSAGE_ID.
Secure Handling of Credentials:
Environment Variables: API keys and IDs are stored only in the .env file.
.gitignore: The .env file is explicitly excluded from Git commits. Ensure .env.example is committed instead.
Configuration Service: NestJS's ConfigService is used to load these variables securely at runtime.
Production Environments: In production, avoid .env files. Use the deployment environment's mechanism for managing secrets (e.g., AWS Secrets Manager, Kubernetes Secrets, Platform Environment Variables).
Fallback Mechanisms:
Direct fallback for OTP delivery failure is challenging. If Infobip experiences an outage:
Error Handling: Our OtpService catches errors from Infobip. The API response will indicate failure.
User Feedback: The client application should inform the user that OTP sending failed and suggest retrying later (respecting rate limits).
Monitoring: Set up monitoring to detect Infobip API errors proactively.
Alternative Channels (Advanced): While Infobip supports other channels (Voice, Email), implementing automatic fallback adds significant complexity. Handling SMS failure gracefully is usually sufficient.
Error Handling, Logging, and Retry Mechanisms
Robust error handling and logging are crucial for production systems.
Error Handling Strategy:
Service Layer: The OtpService catches AxiosErrors, logs details, and throws standardized errors.
Controller Layer: Catches errors from the service.
Global Exception Filter (Recommended): Implement a global filter for consistent error responses.
Create the filter in src/common/filters/http-exception.filter.ts:
TypeScript
// src/common/filters/http-exception.filter.tsimport { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from'@nestjs/common';
import { Request, Response } from'express';
@Catch() // Catch all exceptions if no specific type is providedexportclassAllExceptionsFilterimplementsExceptionFilter {
privatereadonly logger = newLogger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status =
exception instanceofHttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceofHttpException
? exception.getResponse()
: 'Internal server error';
// Log the detailed errorthis.logger.error(
`HTTP Status: ${status} Error Message: ${JSON.stringify(message)} Request: ${request.method}${request.url}`,
exception instanceofError ? exception.stack : '',
'AllExceptionsFilter', // Context
);
response.status(status).json({
statusCode: status,
timestamp: newDate().toISOString(),
path: request.url,
message: message, // Use the message from HttpException or the generic one
});
}
}
Register the filter in main.ts:
TypeScript
// src/main.tsimport { NestFactory } from'@nestjs/core';
import { AppModule } from'./app.module';
import { AllExceptionsFilter } from'./common/filters/http-exception.filter';
import { Logger, ValidationPipe } from'@nestjs/common'; // Import Logger and ValidationPipeasyncfunctionbootstrap() {
const app = awaitNestFactory.create(AppModule, {
// Optionally use NestJS built-in logger or a custom one like Pinologger: ['log', 'error', 'warn', 'debug', 'verbose'],
});
// Enable global validation pipe
app.useGlobalPipes(newValidationPipe({ whitelist: true, transform: true }));
// Enable global exception filter
app.useGlobalFilters(newAllExceptionsFilter());
// Enable CORS if your frontend is on a different domain
app.enableCors(); // Configure origins for production (see Section 7, if applicable)const port = process.env.PORT || 3000;
await app.listen(port);
Logger.log(`__ Application is running on: http://localhost:${port}`, 'Bootstrap'); // Use Logger
}
bootstrap();
Logging:
NestJS Logger: We use the built-in Logger (@nestjs/common).
Contextual Logging: Logs include class names for context.
Key Events: Log OTP send/verify requests, success/failure events, and errors.
Structured Logging (Production): Consider pino with nestjs-pino for JSON logs suitable for aggregation tools (Datadog, Splunk, ELK).
Retry Mechanisms:
OTP Send:Avoid automatic retries. This can confuse users and increase costs. Return an error and let the user retry manually (subject to rate limiting).
OTP Verify: Automatic retries are generally not useful. Infobip handles user-level retries (pinAttempts). Our rate limiter prevents API abuse.
Database Schema and Data Layer
For this specific guide focusing solely on Infobip interaction, a dedicated database for managing the OTP state itself is not required, as Infobip handles the pinId lifecycle (expiration, attempts).
However, in a real-world application, you would integrate this OTP service with a user management system, likely requiring a database for:
User Data: Storing user profiles (ID, phone number, etc.).
Linking pinId to Context: This is crucial. When an OTP is sent (e.g., during login), the application often needs to temporarily store the received pinId associated with the specific user or session attempting the action. This ensures that when the user submits the OTP for verification, the application verifies it against the correct context (e.g., the pending login attempt for that user). This temporary storage might happen in user sessions, a Redis cache, or a database table with short TTLs.
Audit Logs: Recording OTP send/verify attempts for security monitoring and compliance.
Rate Limiting Enhancement: While @nestjs/throttler provides IP-based limiting, storing per-user OTP request counts in a database enables more granular control.
Example Database Integration Pattern:
When integrating with a user database (using TypeORM, Prisma, or similar):
User Entity: Include fields like id, email, phoneNumber, isPhoneVerified.
Session/Cache: Store pinId temporarily (e.g., in Redis with 10-minute expiration) keyed by user ID or session ID.
OTP Flow:
User requests OTP → Generate pinId via Infobip → Store pinId in cache linked to user session
User submits OTP → Retrieve pinId from cache → Verify with Infobip → Update user status if successful
This guide intentionally omits database setup to focus on the Infobip integration. Refer to the NestJS documentation for TypeORM or Prisma integration guides.
Frequently asked questions
Use the /otp/send endpoint with a POST request containing the recipient's phone number in E.164 format. The NestJS application will interact with the Infobip 2FA API to generate and send the OTP via SMS, returning a unique pinId for verification. Ensure your request body includes a phoneNumber field formatted correctly as a string, for example, '+14155552671'.
First, obtain an Infobip account, Base URL, and API Key. Then, create a 2FA application and message template in the Infobip portal. Store the applicationId, messageId, Base URL, and API Key securely in your .env file, loaded using NestJS's ConfigService.
The pinId is a unique identifier generated by the Infobip API after sending an OTP. It's crucial for verifying the OTP code submitted by the user. Your NestJS application should temporarily store the pinId linked to the user's pending action (e.g. login, registration).
These libraries facilitate request body validation using decorators in your DTOs (Data Transfer Objects). class-validator provides decorators like @IsNotEmpty, @IsPhoneNumber, etc., while class-transformer handles transformations between plain objects and class instances.
A global exception filter is highly recommended for production applications to handle errors consistently. It provides centralized error logging and standardized error responses, improving maintainability and the user experience.
Inject the HttpService from @nestjs/axios into your NestJS service. Use it to make POST requests to Infobip's 2FA API endpoints, setting appropriate headers, including the Authorization header with your API key. Manage all API credentials using environment variables and the ConfigService.
Send a POST request to the /otp/verify endpoint, including the pinId obtained during the send request and the user-entered otpCode in the request body. The NestJS application verifies the code against Infobip, returning a boolean verified status in the response.
The @nestjs/throttler module provides rate limiting to prevent API abuse. This mitigates brute-force attacks on the /otp/send and /otp/verify endpoints and safeguards your application.
Yes, use the @Throttle decorator on individual controller methods to override global rate limiting settings from ThrottlerModule.forRoot. This allows for fine-grained control, such as permitting more verify attempts than send requests.
TypeScript enhances JavaScript with static typing, improving code quality, maintainability, and developer experience. It makes large projects like this NestJS application easier to manage, debug, and scale.
An LTS (Long Term Support) version like Node.js v18 or v20 is recommended for stability and maintenance. These versions receive security updates and performance improvements for an extended period.
Implement a dedicated error handling method in your service to catch AxiosError instances. This provides an opportunity to log details like error messages, response data, and status codes, along with appropriate context.
The client application initiates OTP requests to the NestJS API, which acts as an intermediary for interacting with the Infobip 2FA API. This setup decoupled direct client interaction with Infobip, providing greater flexibility and control over the authentication flow.
Build a secure One-Time Password (OTP) system for Two-Factor Authentication (2FA) in your Node.js application using NestJS and the Infobip 2FA API. This guide covers everything from initial setup to production deployment.
You'll create a robust, scalable OTP service that integrates seamlessly into user authentication flows – registration, login, or sensitive action confirmation. By the end, you'll have a functional NestJS API that sends OTPs via SMS and verifies user-submitted codes, backed by Infobip's global communication infrastructure.
Project Overview and Goals
What You'll Build:
A NestJS-based microservice or module that:
Problem Solved:
Enhance your application security by adding a second authentication factor. Prevent unauthorized access even if passwords are compromised by verifying possession of a trusted device (the user's phone).
Technologies Used:
@nestjs/axioswrapper).@nestjs/configwrapper).System Architecture:
The interaction flow is as follows:
POSTrequest to the NestJS OTP API endpoint (e.g.,/otp/send) containing the user's phone number.pinId. The NestJS application might temporarily store thispinIdlinked to the user's session or action.POSTrequest to the NestJS OTP API endpoint (e.g.,/otp/verify) containing thepinIdreceived earlier and the OTP code entered by the user.pinIdand the submitted OTP code.verified: true/false).Prerequisites:
curl).Setting Up Your Project
Bootstrap a new NestJS project and install the necessary dependencies.
Step 1: Create a New NestJS Project
Open your terminal and run the NestJS CLI command:
npx @nestjs/cli new nestjs-infobip-otp cd nestjs-infobip-otpChoose your preferred package manager (npm or yarn) when prompted.
Step 2: Install Dependencies
We need modules for configuration, making HTTP requests, validation, and rate limiting.
# Using npm npm install @nestjs/config axios @nestjs/axios class-validator class-transformer @nestjs/throttler # Using yarn yarn add @nestjs/config axios @nestjs/axios class-validator class-transformer @nestjs/throttlerStep 3: Configure Environment Variables
Create a
.envfile in your project root. This file stores sensitive information like API keys and configuration IDs. Never commit this file to version control.Retrieve Your Infobip Credentials:
INFOBIP_BASE_URL&INFOBIP_API_KEY:.envfile. Treat the API Key like a password.INFOBIP_APP_ID&INFOBIP_MESSAGE_ID:POSTrequest to{INFOBIP_BASE_URL}/2fa/1/applications.curl --request POST \ --url YOUR_INFOBIP_BASE_URL/2fa/1/applications \ --header 'Authorization: App YOUR_INFOBIP_API_KEY' \ --header 'Content-Type: application/json' \ --header 'Accept: application/json' \ --data '{ ""name"": ""My NestJS App Verification"", ""configuration"": { ""pinAttempts"": 5, ""allowMultiplePinVerifications"": true, ""pinTimeToLive"": ""10m"", ""verifyPinLimit"": ""1/3s"", ""sendPinPerApplicationLimit"": ""10000/1d"", ""sendPinPerPhoneNumberLimit"": ""3/1d"" }, ""enabled"": true }'applicationId. Copy this value intoINFOBIP_APP_IDin your.envfile.curlor Postman to send aPOSTrequest to{INFOBIP_BASE_URL}/2fa/1/applications/{INFOBIP_APP_ID}/messages. Replace{INFOBIP_APP_ID}with the ID you just received.curl --request POST \ --url YOUR_INFOBIP_BASE_URL/2fa/1/applications/YOUR_INFOBIP_APP_ID/messages \ --header 'Authorization: App YOUR_INFOBIP_API_KEY' \ --header 'Content-Type: application/json' \ --header 'Accept: application/json' \ --data '{ ""pinType"": ""NUMERIC"", ""messageText"": ""Your verification code is {{pin}}"", ""pinLength"": 6, ""senderId"": ""InfoSMS"" }'senderIds depending on the country and regulations.InfoSMSis often a default shared sender. Check Infobip documentation for details.messageId. Copy this value intoINFOBIP_MESSAGE_IDin your.envfile.Step 4: Load Environment Variables Using ConfigModule
Update
src/app.module.tsto load and validate your environment variables.// src/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'; // Create this next import { ThrottlerModule } from '@nestjs/throttler'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, // Make ConfigService available globally envFilePath: '.env', // Add validation using Joi if needed }), // Configure rate limiting (adjust ttl and limit as needed) ThrottlerModule.forRoot([{ ttl: parseInt(process.env.THROTTLE_TTL || '60', 10) * 1000, // Convert seconds to milliseconds limit: parseInt(process.env.THROTTLE_LIMIT || '10', 10), }]), OtpModule, // Import the OTP module ], controllers: [AppController], providers: [AppService], }) export class AppModule {}Step 5: Add
.envto.gitignoreEnsure your
.gitignorefile includes.env:Create a
.env.examplefile listing required variables (without values) and commit it to your repository. This helps collaborators set up their environment.Implementing Core Functionality
Create a dedicated module (
OtpModule) containing a service (OtpService) to handle Infobip API interactions.Step 1: Generate the OTP Module and Service
Use the NestJS CLI:
nest g module otp nest g service otp --no-spec # --no-spec skips generating a test file for nowStep 2: Configure HttpModule for Axios
Make the
HttpModuleavailable within yourOtpModuleto injectHttpService.// src/otp/otp.module.ts import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; import { OtpService } from './otp.service'; // Import OtpController when you create it // import { OtpController } from './otp.controller'; @Module({ imports: [ // Configure HttpModule with timeout options HttpModule.register({ timeout: 5000, // 5 seconds maxRedirects: 5, }), ], providers: [OtpService], // controllers: [OtpController], // Uncomment later exports: [OtpService], // Export if other modules need it }) export class OtpModule {}Step 3: Implement the OtpService
This service will contain the methods for sending and verifying OTPs.
// src/otp/otp.service.ts import { Injectable, Logger } from '@nestjs/common'; import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; import { firstValueFrom } from 'rxjs'; import { AxiosError } from 'axios'; @Injectable() export class OtpService { private readonly logger = new Logger(OtpService.name); private readonly infobipBaseUrl: string; private readonly infobipApiKey: string; private readonly infobipAppId: string; private readonly infobipMessageId: string; constructor( private readonly httpService: HttpService, private readonly configService: ConfigService, ) { // Load Infobip credentials from environment variables this.infobipBaseUrl = this.configService.get<string>('INFOBIP_BASE_URL'); this.infobipApiKey = this.configService.get<string>('INFOBIP_API_KEY'); this.infobipAppId = this.configService.get<string>('INFOBIP_APP_ID'); this.infobipMessageId = this.configService.get<string>('INFOBIP_MESSAGE_ID'); if (!this.infobipBaseUrl || !this.infobipApiKey || !this.infobipAppId || !this.infobipMessageId) { this.logger.error('Infobip configuration is missing in environment variables.'); throw new Error('Infobip configuration is incomplete.'); } } /** * Sends an OTP SMS to the specified phone number via Infobip. * @param phoneNumber - Recipient's phone number in E.164 format (e.g., +14155552671). * @returns The pinId generated by Infobip, used for verification. * @throws Error if the API call fails. */ async sendOtp(phoneNumber: string): Promise<string> { // Note: Always refer to official Infobip API documentation for current endpoints and versions. const url = `${this.infobipBaseUrl}/2fa/2/pin`; const payload = { applicationId: this.infobipAppId, messageId: this.infobipMessageId, from: 'InfoSMS', // Or your registered sender ID to: phoneNumber, }; const headers = { 'Authorization': `App ${this.infobipApiKey}`, 'Content-Type': 'application/json', 'Accept': 'application/json', }; this.logger.log(`Sending OTP to ${phoneNumber} via Infobip`); try { const response = await firstValueFrom( this.httpService.post(url, payload, { headers }), ); this.logger.log(`Infobip Send OTP Response Status: ${response.status}`); // Validate response structure if (!response.data || !response.data.pinId) { this.logger.error('Infobip response missing pinId', response.data); throw new Error('Failed to send OTP: Invalid response structure from Infobip.'); } return response.data.pinId; } catch (error) { this.handleInfobipError(error, 'sendOtp'); // handleInfobipError throws, so this line is for type safety throw new Error('Failed to send OTP'); } } /** * Verifies an OTP code using the pinId provided by Infobip. * @param pinId - The ID received after successfully sending the OTP. * @param otpCode - The OTP code entered by the user. * @returns True if the OTP is verified successfully, false otherwise. * @throws Error if the API call fails or returns an unexpected status. */ async verifyOtp(pinId: string, otpCode: string): Promise<boolean> { const url = `${this.infobipBaseUrl}/2fa/2/pin/${pinId}/verify`; const payload = { pin: otpCode, }; const headers = { 'Authorization': `App ${this.infobipApiKey}`, 'Content-Type': 'application/json', 'Accept': 'application/json', }; this.logger.log(`Verifying OTP for pinId: ${pinId}`); try { const response = await firstValueFrom( this.httpService.post(url, payload, { headers }), ); this.logger.log(`Infobip Verify OTP Response Status: ${response.status}`); // Check the verified status in the response data if (response.data && response.data.verified === true) { this.logger.log(`OTP verified successfully for pinId: ${pinId}`); return true; } else { // Log the reason if available (e.g., attempts exceeded, wrong pin) this.logger.warn(`OTP verification failed for pinId: ${pinId}. Reason: ${response.data?.ncStatus || response.data?.status || 'Unknown'}`, response.data); return false; } } catch (error) { // Handle verification failures due to wrong pin or expired OTP // These might return 4xx errors from Infobip if (error instanceof AxiosError && error.response) { this.logger.warn(`Infobip API verification error for pinId ${pinId}: ${error.response.status}`, error.response.data); // Check specific Infobip error codes if needed // e.g., if (error.response.data.requestError?.serviceException?.messageId === 'PIN_NOT_VALID') return false; return false; // Treat non-2xx as verification failure } // Handle other errors (network, config) this.handleInfobipError(error, 'verifyOtp'); return false; } } /** * Handles errors from Axios/Infobip API calls. */ private handleInfobipError(error: any, context: string): void { if (error instanceof AxiosError) { this.logger.error( `Infobip API Error during ${context}: ${error.response?.status} ${error.message}`, error.response?.data || error.stack, // Log response data if available ); // Re-throw a more specific error or a generic one throw new Error(`Infobip API request failed during ${context}. Status: ${error.response?.status}`); } else { this.logger.error(`Unexpected error during ${context}: ${error.message}`, error.stack); throw new Error(`An unexpected error occurred during ${context}.`); } } }Explanation:
HttpService(for making requests) andConfigService(for environment variables).sendOtp: Constructs the URL and payload for Infobip's/2fa/2/pinendpoint, sets theAuthorizationheader using the API key, makes the POST request, and returns thepinIdfrom the response.verifyOtp: Constructs the URL (/2fa/2/pin/{pinId}/verify) and payload, makes the POST request, and checks theverifiedfield in the response. Returnstrueorfalse.handleInfobipError: A private helper to log errors consistently, distinguishing between Axios HTTP errors and other unexpected errors. It re-throws an error to be caught higher up (e.g., in the controller or an exception filter).Building Your API Layer
Expose the OTP functionality through a controller with specific endpoints.
Step 1: Create Data Transfer Objects (DTOs) for Validation
Create files for request body validation.
Create
SendOtpDtoinsrc/otp/dto/send-otp.dto.ts:// src/otp/dto/send-otp.dto.ts import { IsNotEmpty, IsString, IsPhoneNumber } from 'class-validator'; export class SendOtpDto { @IsNotEmpty() @IsString() // Ensure input is in E.164 format for Infobip @IsPhoneNumber(null, { message: 'Phone number must be a valid E.164 format string (e.g., +14155552671)' }) phoneNumber: string; }Create
VerifyOtpDtoinsrc/otp/dto/verify-otp.dto.ts:// src/otp/dto/verify-otp.dto.ts import { IsNotEmpty, IsString, Length } from 'class-validator'; export class VerifyOtpDto { @IsNotEmpty() @IsString() pinId: string; // The ID received from the /send endpoint @IsNotEmpty() @IsString() @Length(4, 8, { message: 'OTP code must be between 4 and 8 digits' }) // Adjust based on your pinLength otpCode: string; }SendOtpDto: Requires aphoneNumberfield in E.164 format (e.g.,+14155552671).VerifyOtpDto: RequirespinId(received from the send request) andotpCode(entered by user), validating length based on your message template.Step 2: Generate the OTP Controller
Step 3: Implement the OtpController
// src/otp/otp.controller.ts import { Controller, Post, Body, ValidationPipe, UsePipes, Logger, HttpCode, HttpStatus, UseGuards } from '@nestjs/common'; import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; import { OtpService } from './otp.service'; import { SendOtpDto } from './dto/send-otp.dto'; import { VerifyOtpDto } from './dto/verify-otp.dto'; @Controller('otp') export class OtpController { private readonly logger = new Logger(OtpController.name); constructor(private readonly otpService: OtpService) {} /** * Endpoint to request sending an OTP SMS. * Applies rate limiting. */ @UseGuards(ThrottlerGuard) // Apply the global throttler guard @Throttle({ default: { limit: 3, ttl: 60000 } }) // Override: Max 3 requests per 60 seconds per user/IP @Post('send') @HttpCode(HttpStatus.OK) // Return 200 OK on success @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) // Enable validation async sendOtp(@Body() sendOtpDto: SendOtpDto): Promise<{ pinId: string; message: string }> { this.logger.log(`Received request to send OTP to: ${sendOtpDto.phoneNumber}`); const pinId = await this.otpService.sendOtp(sendOtpDto.phoneNumber); this.logger.log(`OTP sent successfully, pinId: ${pinId}`); return { pinId: pinId, message: 'OTP sent successfully. Please verify.' }; } /** * Endpoint to verify an OTP code. * Applies rate limiting. */ @UseGuards(ThrottlerGuard) @Throttle({ default: { limit: 5, ttl: 60000 } }) // Override: Max 5 verification attempts per 60 seconds @Post('verify') @HttpCode(HttpStatus.OK) // Return 200 OK regardless of verification success/fail @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) async verifyOtp(@Body() verifyOtpDto: VerifyOtpDto): Promise<{ verified: boolean; message: string }> { this.logger.log(`Received request to verify OTP for pinId: ${verifyOtpDto.pinId}`); const isVerified = await this.otpService.verifyOtp(verifyOtpDto.pinId, verifyOtpDto.otpCode); if (isVerified) { this.logger.log(`OTP verification successful for pinId: ${verifyOtpDto.pinId}`); return { verified: true, message: 'OTP verified successfully.' }; } else { this.logger.warn(`OTP verification failed for pinId: ${verifyOtpDto.pinId}`); return { verified: false, message: 'OTP verification failed. Invalid code or expired.' }; } } }Step 4: Register the Controller
Uncomment the controller in
src/otp/otp.module.ts:// src/otp/otp.module.ts import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; import { OtpService } from './otp.service'; import { OtpController } from './otp.controller'; // Import controller @Module({ imports: [ HttpModule.register({ // Moved HttpModule config here timeout: 5000, maxRedirects: 5, }), ], providers: [OtpService], controllers: [OtpController], // Register controller exports: [OtpService], }) export class OtpModule {}Explanation:
POSTendpoints:/otp/sendand/otp/verify.@UsePipes(new ValidationPipe(...))automatically validates incoming request bodies against the DTOs (SendOtpDto,VerifyOtpDto).whitelist: truestrips any properties not defined in the DTO.@UseGuards(ThrottlerGuard)applies the global rate limiting configured inAppModule.@Throttle(...)allows overriding the global limits for specific endpoints. We allow fewersendrequests thanverifyrequests per time window.OtpService.pinIdon send,verifiedboolean on verify).@HttpCode(HttpStatus.OK)ensures a 200 status code is returned even if verification fails (as the API call itself succeeded). Theverifiedflag in the response body indicates the outcome.Testing with
curl:Start the application:
npm run start:devoryarn start:devSend OTP:
curl --location --request POST 'http://localhost:3000/otp/send' \ --header 'Content-Type: application/json' \ --data '{ ""phoneNumber"": ""+14155552671"" # Replace with a real phone number accessible to you }'{ ""pinId"": ""SOME_PIN_ID_FROM_INFOBIP"", ""message"": ""OTP sent successfully. Please verify."" }Verify OTP: Use the
pinIdfrom the previous response and the code from the SMS.curl --location --request POST 'http://localhost:3000/otp/verify' \ --header 'Content-Type: application/json' \ --data '{ ""pinId"": ""SOME_PIN_ID_FROM_INFOBIP"", # Use the actual pinId received ""otpCode"": ""123456"" # Use the actual code from the SMS }'{ ""verified"": true, ""message"": ""OTP verified successfully."" }{ ""verified"": false, ""message"": ""OTP verification failed. Invalid code or expired."" }Integrating with Infobip
This section summarizes the Infobip-specific setup. Refer to the Setting Up Your Project section, Step 3 for detailed
curlexamples if needed.Configuration Steps:
.envfile asINFOBIP_BASE_URLandINFOBIP_API_KEY.POST /2fa/1/applications) to create a 2FA application.pinAttempts,pinTimeToLive, etc.applicationIdin.envasINFOBIP_APP_ID.POST /2fa/1/applications/{APP_ID}/messages) to create a message template linked to your application.messageText(including{{pin}}),pinType,pinLength, and optionallysenderId.messageIdin.envasINFOBIP_MESSAGE_ID.Secure Handling of Credentials:
.envfile..gitignore: The.envfile is explicitly excluded from Git commits. Ensure.env.exampleis committed instead.ConfigServiceis used to load these variables securely at runtime..envfiles. Use the deployment environment's mechanism for managing secrets (e.g., AWS Secrets Manager, Kubernetes Secrets, Platform Environment Variables).Fallback Mechanisms:
Direct fallback for OTP delivery failure is challenging. If Infobip experiences an outage:
OtpServicecatches errors from Infobip. The API response will indicate failure.Error Handling, Logging, and Retry Mechanisms
Robust error handling and logging are crucial for production systems.
Error Handling Strategy:
OtpServicecatchesAxiosErrors, logs details, and throws standardized errors.Create the filter in
src/common/filters/http-exception.filter.ts:// src/common/filters/http-exception.filter.ts import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common'; import { Request, Response } from 'express'; @Catch() // Catch all exceptions if no specific type is provided export class AllExceptionsFilter implements ExceptionFilter { private readonly logger = new Logger(AllExceptionsFilter.name); catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse<Response>(); const request = ctx.getRequest<Request>(); const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; const message = exception instanceof HttpException ? exception.getResponse() : 'Internal server error'; // Log the detailed error this.logger.error( `HTTP Status: ${status} Error Message: ${JSON.stringify(message)} Request: ${request.method} ${request.url}`, exception instanceof Error ? exception.stack : '', 'AllExceptionsFilter', // Context ); response.status(status).json({ statusCode: status, timestamp: new Date().toISOString(), path: request.url, message: message, // Use the message from HttpException or the generic one }); } }main.ts:// src/main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { AllExceptionsFilter } from './common/filters/http-exception.filter'; import { Logger, ValidationPipe } from '@nestjs/common'; // Import Logger and ValidationPipe async function bootstrap() { const app = await NestFactory.create(AppModule, { // Optionally use NestJS built-in logger or a custom one like Pino logger: ['log', 'error', 'warn', 'debug', 'verbose'], }); // Enable global validation pipe app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); // Enable global exception filter app.useGlobalFilters(new AllExceptionsFilter()); // Enable CORS if your frontend is on a different domain app.enableCors(); // Configure origins for production (see Section 7, if applicable) const port = process.env.PORT || 3000; await app.listen(port); Logger.log(`__ Application is running on: http://localhost:${port}`, 'Bootstrap'); // Use Logger } bootstrap();Logging:
Logger(@nestjs/common).pinowithnestjs-pinofor JSON logs suitable for aggregation tools (Datadog, Splunk, ELK).Retry Mechanisms:
pinAttempts). Our rate limiter prevents API abuse.Database Schema and Data Layer
For this specific guide focusing solely on Infobip interaction, a dedicated database for managing the OTP state itself is not required, as Infobip handles the
pinIdlifecycle (expiration, attempts).However, in a real-world application, you would integrate this OTP service with a user management system, likely requiring a database for:
pinIdto Context: This is crucial. When an OTP is sent (e.g., during login), the application often needs to temporarily store the receivedpinIdassociated with the specific user or session attempting the action. This ensures that when the user submits the OTP for verification, the application verifies it against the correct context (e.g., the pending login attempt for that user). This temporary storage might happen in user sessions, a Redis cache, or a database table with short TTLs.@nestjs/throttlerprovides IP-based limiting, storing per-user OTP request counts in a database enables more granular control.Example Database Integration Pattern:
When integrating with a user database (using TypeORM, Prisma, or similar):
id,email,phoneNumber,isPhoneVerified.pinIdtemporarily (e.g., in Redis with 10-minute expiration) keyed by user ID or session ID.pinIdvia Infobip → StorepinIdin cache linked to user sessionpinIdfrom cache → Verify with Infobip → Update user status if successfulThis guide intentionally omits database setup to focus on the Infobip integration. Refer to the NestJS documentation for TypeORM or Prisma integration guides.