code examples
code examples
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/axioswrapper). - dotenv: For managing environment variables securely (
@nestjs/configwrapper). - 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
POSTrequest 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 thispinIdlinked 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
POSTrequest to the NestJS OTP API endpoint (e.g.,/otp/verify) containing thepinIdreceived 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
pinIdand 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:
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 .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
# 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
.envfile. 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
POSTrequest to{INFOBIP_BASE_URL}/2fa/1/applications.bashcurl --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 }'- The response will contain an
applicationId. Copy this value intoINFOBIP_APP_IDin your.envfile.
- The response will contain an
- Create Message Template: Use
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.bashcurl --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"" }'- Note: You might need to register
senderIds depending on the country and regulations.InfoSMSis often a default shared sender. Check Infobip documentation for details. - The response will contain a
messageId. Copy this value intoINFOBIP_MESSAGE_IDin your.envfile.
- Note: You might need to register
Step 4: Load Environment Variables Using ConfigModule
Update src/app.module.ts to 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 .env to .gitignore
Ensure your .gitignore file includes .env:
# .gitignore (ensure these lines exist)
/dist
/node_modules
.envCreate 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:
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 HttpModule available within your OtpModule to inject HttpService.
// 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:
- We inject
HttpService(for making requests) andConfigService(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/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 SendOtpDto in src/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 VerifyOtpDto in src/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
nest g controller otp --no-specStep 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:
- The controller defines two
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.- The controller methods delegate the core logic to
OtpService. - Responses provide clear messages and status (
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:dev -
Send OTP:
bashcurl --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.
- Expected Response (Success):
-
Verify OTP: Use the
pinIdfrom the previous response and the code from the SMS.bashcurl --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 }'- Expected Response (Success):
json
{ ""verified"": true, ""message"": ""OTP verified successfully."" } - Expected Response (Failure - wrong code):
json
{ ""verified"": false, ""message"": ""OTP verification failed. Invalid code or expired."" }
- Expected Response (Success):
Integrating with Infobip
This section summarizes the Infobip-specific setup. Refer to the Setting Up Your Project section, Step 3 for detailed curl examples if needed.
Configuration Steps:
- Obtain Infobip Account: Sign up at https://www.infobip.com/.
- Get Base URL and API Key:
- Log in to the Infobip portal.
- Find your unique API Base URL and create an API Key.
- Store these in your
.envfile asINFOBIP_BASE_URLandINFOBIP_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
applicationIdin.envasINFOBIP_APP_ID.
- Use the Infobip API (
- 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 optionallysenderId. - Store the returned
messageIdin.envasINFOBIP_MESSAGE_ID.
- Use the Infobip API (
Secure Handling of Credentials:
- Environment Variables: API keys and IDs are stored only in the
.envfile. .gitignore: The.envfile is explicitly excluded from Git commits. Ensure.env.exampleis committed instead.- Configuration Service: NestJS's
ConfigServiceis used to load these variables securely at runtime. - Production Environments: In production, avoid
.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:
- Error Handling: Our
OtpServicecatches 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
OtpServicecatchesAxiosErrors, 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:
// 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
});
}
}- Register the filter in
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:
- 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
pinowithnestjs-pinofor 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
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. - Audit Logs: Recording OTP send/verify attempts for security monitoring and compliance.
- Rate Limiting Enhancement: While
@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):
- User Entity: Include fields like
id,email,phoneNumber,isPhoneVerified. - Session/Cache: Store
pinIdtemporarily (e.g., in Redis with 10-minute expiration) keyed by user ID or session ID. - OTP Flow:
- User requests OTP → Generate
pinIdvia Infobip → StorepinIdin cache linked to user session - User submits OTP → Retrieve
pinIdfrom cache → Verify with Infobip → Update user status if successful
- User requests OTP → Generate
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
How to send OTP SMS messages with NestJS?
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'.
How to set up Infobip 2FA for NestJS OTP?
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`.
What is the purpose of pinId in Infobip OTP?
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).
Why does NestJS use class-validator and class-transformer?
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.
When should I implement a global exception filter in NestJS?
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.
How to integrate Infobip API with a NestJS service?
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`.
How to verify OTP code received via SMS?
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.
What is the role of @nestjs/throttler in OTP flow?
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.
Can I customize rate limiting for specific endpoints?
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.
Why does this project use TypeScript?
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.
What is the recommended Node.js version for this project?
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.
How to handle Infobip API errors in NestJS?
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.
What is the system architecture of the NestJS Infobip OTP implementation?
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.