This guide provides a complete walkthrough for implementing a robust One-Time Password (OTP) system, often used for Two-Factor Authentication (2FA), within a Node.js application using the NestJS framework and the Infobip 2FA API. We'll cover everything from initial project setup to deployment considerations, ensuring you have a production-ready solution.
We aim to build a secure, reliable, and scalable OTP service that can be easily integrated into various user authentication flows like registration, login, or sensitive action confirmation. By the end, you'll have a functional NestJS API capable of sending OTPs via SMS and verifying user-submitted codes, backed by Infobip's global communication infrastructure.
Project Overview and Goals
What We're Building:
A NestJS-based microservice or module responsible for:
- Generating and sending OTP SMS messages to user-provided phone numbers via the Infobip API.
- Verifying OTP codes submitted by users against the codes generated by Infobip.
Problem Solved:
This implementation addresses the need for enhanced application security by adding a second factor of authentication. It helps prevent unauthorized access even if user passwords are compromised, 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 thispinId
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 thepinId
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, e.g., v18 or v20) and npm/yarn.
- An Infobip Account (a free trial account is sufficient to start).
- Basic understanding of TypeScript and NestJS concepts (modules, controllers, services).
- A code editor (like VS Code).
- A terminal or command prompt.
- Tools like Postman or
curl
for testing the API endpoints.
1. Setting up the project
Let's 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-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.
# 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/throttler
Step 3: Configure Environment Variables
Create a .env
file in the project root directory. This file will store 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)
How to get Infobip Credentials:
-
INFOBIP_BASE_URL
&INFOBIP_API_KEY
:- Log in to your Infobip account.
- Your Base URL is usually displayed prominently on the dashboard homepage or in the API documentation section. It's specific to your account.
- Navigate to the API Keys management section (often under account settings or developer tools).
- Create a new API Key. Give it 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
:- These are specific to the Infobip 2FA service and need to be created via the Infobip API before you can send OTPs. You typically do this once during setup.
- Create 2FA Application: Use
curl
or Postman to send aPOST
request 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 }'
- The response will contain an
applicationId
. Copy this value intoINFOBIP_APP_ID
in your.env
file.
- The response will contain an
- Create Message Template: Use
curl
or Postman to send aPOST
request 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"" }'
- Note: You might need to register
senderId
s 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 intoINFOBIP_MESSAGE_ID
in your.env
file.
- Note: You might need to register
Step 4: Load Environment Variables using ConfigModule
Modify src/app.module.ts
to load and validate the 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'; // We will create this next
import { ThrottlerModule } from '@nestjs/throttler';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // Make ConfigService available globally
envFilePath: '.env', // Specify the env file path
// Add validation here if needed using Joi or similar
}),
// Configure Rate Limiting (adjust ttl and limit as needed)
ThrottlerModule.forRoot([{
ttl: parseInt(process.env.THROTTLE_TTL || '60', 10) * 1000, // Convert THROTTLE_TTL (seconds) to milliseconds
limit: parseInt(process.env.THROTTLE_LIMIT || '10', 10),
}]),
OtpModule, // Import the OTP module
// Add HealthModule here later if implementing health checks
// HealthModule,
],
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
.env
It's also a best practice to create a .env.example
file listing the required variables (without their values) and commit it to your repository. This helps collaborators set up their environment.
2. Implementing core functionality
We'll create a dedicated module (OtpModule
) containing a service (OtpService
) to handle the interaction logic with the Infobip API.
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 now
Step 2: Configure HttpModule for Axios
We need to make the HttpModule
available within our OtpModule
to inject HttpService
.
// src/otp/otp.module.ts
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios'; // Import HttpModule
import { OtpService } from './otp.service';
// Import OtpController later when we create it
// import { OtpController } from './otp.controller';
@Module({
imports: [
// Configure HttpModule with options like timeout
HttpModule.register({
timeout: 5000, // Timeout in milliseconds (e.g., 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 - The 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: API endpoints and versions can change. Always refer to the official Infobip API documentation for the most current paths.
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}`);
// Check if the response structure is as expected
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 will throw, so this line might not be reached,
// but needed for type safety if handleInfobipError is modified.
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) {
// Specifically handle cases where verification fails due to wrong pin/expired etc.
// which might return 4xx errors from Infobip, not necessarily network errors.
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 to distinguish ""wrong pin"" from other errors
// 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');
// This line might not be reached if handleInfobipError throws
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/pin
endpoint, sets theAuthorization
header using the API key, makes the POST request, and returns thepinId
from the response. A note reminds the developer to check Infobip's documentation for current API paths.verifyOtp
: Constructs the URL (/2fa/2/pin/{pinId}/verify
) and payload, makes the POST request, and checks theverified
field in the response. Returnstrue
orfalse
.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).
3. Building a complete API layer
Now, let's 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 the SendOtpDto
in src/otp/dto/send-otp.dto.ts
:
// src/otp/dto/send-otp.dto.ts
import { IsNotEmpty, IsPhoneNumber, IsString } from 'class-validator';
export class SendOtpDto {
@IsNotEmpty()
@IsString()
// Use IsPhoneNumber from class-validator, potentially specifying region
// Ensure input is expected 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 the 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 length based on your Infobip template setting (pinLength)
otpCode: string;
}
SendOtpDto
: Requires aphoneNumber
field, validates it's a non-empty string and attempts basic phone number validation (ensure E.164 format like+14155552671
as Infobip usually requires this).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-spec
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:
- 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 inAppModule
.@Throttle(...)
allows overriding the global limits for specific endpoints. We allow fewersend
requests thanverify
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). Theverified
flag in the response body indicates the outcome.
Testing with curl
:
-
Start the application:
npm run start:dev
oryarn start:dev
-
Send 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 }'
- Expected Response (Success):
{ ""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
pinId
from 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 }'
- Expected Response (Success):
{ ""verified"": true, ""message"": ""OTP verified successfully."" }
- Expected Response (Failure - wrong code):
{ ""verified"": false, ""message"": ""OTP verification failed. Invalid code or expired."" }
- Expected Response (Success):
4. Integrating with necessary third-party services (Infobip)
This section summarizes the Infobip-specific setup. Refer to Section 1, 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
.env
file asINFOBIP_BASE_URL
andINFOBIP_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
asINFOBIP_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
messageId
in.env
asINFOBIP_MESSAGE_ID
.
- Use the Infobip API (
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 (Section 10, if applicable) 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.
5. Implementing proper error handling, logging, and retry mechanisms
Robust error handling and logging are crucial for production systems.
Error Handling Strategy:
- Service Layer: The
OtpService
catchesAxiosError
s, 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
pino
withnestjs-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.
6. Creating a 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 receivedpinId
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 u