code examples
code examples
Implement SMS OTP 2FA in Node.js with Infobip API – Complete Guide
Build secure SMS-based OTP two-factor authentication in Node.js using Infobip 2FA API. Includes Express setup, Redis storage, security best practices, and production deployment.
Implement SMS OTP 2FA in Node.js with Infobip API
Add SMS-based One-Time Password (OTP) verification to your Node.js Express application using the Infobip 2FA API. This comprehensive guide walks you through implementing secure two-factor authentication (2FA), from initial project setup through production deployment with Redis, rate limiting, and NIST compliance.
SMS OTP authentication verifies user possession of a registered phone number, adding a critical security layer to registration, login, and sensitive operations. Even if passwords are compromised, unauthorized access remains blocked without the OTP code sent to the user's phone.
Project Overview and Goals
What You'll Build:
Build a Node.js Express application with two core API endpoints:
/request-otp: Accepts a user's phone number, uses the Infobip API to generate and send an OTP code via SMS to that number, and stores necessary information for verification./verify-otp: Accepts the user's phone number and the OTP code they received, then uses the Infobip API to verify if the code is correct and valid.
Problem Solved:
This implementation adds a layer of security to user actions like registration, login, or sensitive operations by verifying that the user currently possesses the phone number associated with their account. It helps prevent unauthorized access even if account credentials (like passwords) are compromised.
Technologies Used:
- Node.js: The JavaScript runtime environment.
- Express.js: A minimal and flexible Node.js web application framework for building the API.
- Infobip 2FA API: The third-party service used to handle OTP generation, SMS delivery, and verification.
axios: A promise-based HTTP client for making requests to the Infobip API.dotenv: A module to load environment variables from a.envfile.- (Optional)
express-validator: For robust request validation. - (Optional)
express-rate-limit: To protect endpoints from brute-force attacks.
System Architecture:
+-------------+ +-----------------------+ +---------------+ +-------------+
| |--(1)->| Node.js/Express |--(2)->| |--(3)->| |
| End User | | App Server | | Infobip API | | User's Phone|
| (Browser/App)|<- F -| |<- G -| |<- E -| (SMS) |
| |<-(6)--| (API: /verify-otp) |<-(7)--| | | |
| | | (API: /request-otp) |--(4)->| | | |
+-------------+ +-----------------------+ +---------------+ +-------------+
Flow:
1. User initiates OTP request (e.g., provides phone number) to the App Server (/request-otp).
2. App Server sends OTP request details (App ID, Message ID, Phone Number) to Infobip API.
3. Infobip generates OTP, sends SMS to User's Phone.
4. Infobip returns a `pinId` (PIN identifier) to the App Server. (App Server stores this temporarily associated with the phone number).
5. User receives SMS, enters the OTP code into their Browser/App.
6. User submits the OTP code and phone number to the App Server (/verify-otp). App Server includes the stored `pinId`.
7. App Server sends the `pinId` and submitted OTP code to Infobip for verification.
G. Infobip returns verification status (success/failure) to App Server.
F. App Server processes the result and responds to the End User.
(Note: The visual placement of arrows F and G in the diagram might seem slightly ambiguous relative to steps 5/6/7, but the text description G->F reflects the logical sequence of responses after step 7).
Prerequisites:
- Node.js and npm (or yarn) installed.
- An active Infobip account (Sign up for free).
- A registered phone number (for testing during development, preferably the one linked to your Infobip trial account).
- Important: Phone numbers must be in E.164 format (e.g.,
+447700900000for UK,+12125551234for US). This format includes:+prefix, country code, and subscriber number without spaces or special characters. Incorrect formatting is a leading cause of SMS delivery failures. - Basic understanding of Node.js, Express, APIs, and asynchronous JavaScript.
Final Outcome:
A functional Express API capable of sending OTPs via Infobip SMS and verifying them, ready to be integrated into a larger application for enhanced security.
1. Node.js Project Setup and Dependencies
Initialize your Node.js project and install the necessary dependencies.
-
Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.
bashmkdir infobip-otp-guide cd infobip-otp-guide -
Initialize npm:
bashnpm init -yThis creates a
package.jsonfile. -
Install Dependencies: We need Express for the server,
axiosto call the Infobip API, anddotenvto manage environment variables.bashnpm install express axios dotenv -
Install Development Dependencies (Optional but Recommended):
nodemonhelps during development by automatically restarting the server on file changes.bashnpm install --save-dev nodemon -
Configure
nodemon(Optional): Add adevscript to yourpackage.jsonscriptssection:json// package.json { // ... other fields ""scripts"": { ""start"": ""node server.js"", ""dev"": ""nodemon server.js"", ""test"": ""echo \""Error: no test specified\"" && exit 1"" }, // ... other fields } -
Create Project Structure: Organize the project for clarity. Create the following files and folders:
infobip-otp-guide/ ├── node_modules/ ├── .env ├── .gitignore ├── package.json ├── package-lock.json ├── server.js └── services/ └── infobip.js -
Create
.gitignore: Addnode_modulesand.envto prevent committing them to version control.plaintext# .gitignore node_modules .env -
Create
server.js(Initial Setup):javascript// server.js require('dotenv').config(); // Load environment variables early const express = require('express'); const infobipService = require('./services/infobip'); // We will create this soon const app = express(); const PORT = process.env.PORT || 3000; // --- Middleware --- app.use(express.json()); // Parse JSON request bodies // --- In-Memory Storage (DEMO ONLY - NOT FOR PRODUCTION) --- // !! CRITICAL !! In production, use a secure, persistent store like Redis or a database // table with TTL to associate pinId with the user/phone number/session. // In-memory storage is NOT scalable, NOT persistent, and prone to memory leaks if // the cleanup mechanism fails or if many requests are made without verification. const otpStore = {}; // { phoneNumber: pinId } // --- Routes --- app.post('/request-otp', async (req, res) => { const { phoneNumber } = req.body; if (!phoneNumber) { return res.status(400).json({ success: false, error: 'Phone number is required' }); } try { console.log(`Requesting OTP for ${phoneNumber}`); const pinId = await infobipService.sendOtp(phoneNumber); console.log(`OTP request successful for ${phoneNumber}, pinId: ${pinId}`); // Store the pinId temporarily, associating it with the phone number // AGAIN: Replace this with a proper storage solution in production. otpStore[phoneNumber] = pinId; // Basic cleanup for demo purposes. A robust system needs better handling. // This timeout might not run if the server crashes, potentially leaking memory. setTimeout(() => { // Only delete if the pinId hasn't been overwritten by a newer request for the same number if (otpStore[phoneNumber] === pinId) { delete otpStore[phoneNumber]; console.log(`Cleaned up expired/unused pinId for ${phoneNumber}`); } }, 10 * 60 * 1000); // 10 minutes (adjust based on pinTimeToLive) res.status(200).json({ success: true, message: 'OTP sent successfully.' }); } catch (error) { console.error('Error sending OTP:', error.response?.data || error.message); // Provide a generic error message to the client res.status(500).json({ success: false, error: 'Failed to send OTP. Please try again later.', // Optionally include details in logs or specific non-sensitive error codes errorCode: error.response?.data?.requestError?.serviceException?.messageId || 'UNKNOWN_ERROR' }); } }); app.post('/verify-otp', async (req, res) => { const { phoneNumber, otp } = req.body; if (!phoneNumber || !otp) { return res.status(400).json({ success: false, error: 'Phone number and OTP are required' }); } // Retrieve the pinId associated with this phone number (from demo store) const pinId = otpStore[phoneNumber]; if (!pinId) { console.warn(`No active OTP request found for ${phoneNumber}. It might have expired or never existed.`); return res.status(400).json({ success: false, error: 'No active OTP request found or it has expired. Please request a new OTP.' }); } try { console.log(`Verifying OTP ${otp} for ${phoneNumber} with pinId ${pinId}`); const isVerified = await infobipService.verifyOtp(pinId, otp); if (isVerified) { console.log(`OTP verification successful for ${phoneNumber}`); // Verification successful - remove the used pinId from store immediately delete otpStore[phoneNumber]; res.status(200).json({ success: true, message: 'OTP verified successfully.' }); // TODO: Update user status in your database (e.g., mark phone as verified) } else { console.warn(`OTP verification failed for ${phoneNumber}. Incorrect PIN.`); // Verification failed (likely incorrect PIN) // NOTE: Infobip handles attempt limits based on Application settings res.status(400).json({ success: false, error: 'Invalid OTP code.' }); // Consider if you want to clear pinId from store on failure based on app logic } } catch (error) { console.error('Error verifying OTP:', error.response?.data || error.message); // Check for specific Infobip errors if needed const errorCode = error.response?.data?.requestError?.serviceException?.messageId; let errorMessage = 'Failed to verify OTP. Please try again later.'; if (errorCode === 'TOO_MANY_REQUESTS') { errorMessage = 'Too many verification attempts. Please try again later.'; } else if (errorCode === 'PIN_EXPIRED') { errorMessage = 'OTP code has expired. Please request a new one.'; // Clean up expired pin from store if it matches the one we tried to verify if (otpStore[phoneNumber] === pinId) delete otpStore[phoneNumber]; } else if (errorCode === 'PIN_NOT_FOUND') { errorMessage = 'Invalid request. Please request a new OTP.'; // Clean up potentially invalid pin from store if it matches if (otpStore[phoneNumber] === pinId) delete otpStore[phoneNumber]; } res.status(500).json({ success: false, error: errorMessage, errorCode: errorCode || 'UNKNOWN_ERROR' }); } }); // --- Start Server --- app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); infobipService.initialize() .then(() => console.log('Infobip service initialized.')) .catch(err => console.error('Failed to initialize Infobip service:', err)); }); -
Create
services/infobip.js: This file will contain the logic for interacting with the Infobip API.javascript// services/infobip.js const axios = require('axios'); // --- Configuration --- // These should come from environment variables const INFOBIP_API_KEY = process.env.INFOBIP_API_KEY; const INFOBIP_BASE_URL = process.env.INFOBIP_BASE_URL; // Example values - Consider making these configurable via .env or other means const APP_NAME = process.env.INFOBIP_APP_NAME || 'MyNodejsApp 2FA'; // Example name const SENDER_ID = process.env.INFOBIP_SENDER_ID || 'InfoSMS'; // Example sender - may need registration const MESSAGE_TEMPLATE_TEXT = process.env.INFOBIP_MESSAGE_TEMPLATE || 'Your verification PIN is {{pin}}. It expires in 5 minutes.'; // Example template text // --- State (to store IDs retrieved from Infobip) --- // Populated from .env or dynamically created on first run. let applicationId = process.env.INFOBIP_APPLICATION_ID; let messageId = process.env.INFOBIP_MESSAGE_ID; // --- Axios Instance --- let infobipAxios; // Define here, initialize after checking config // --- Helper Functions --- /** * Creates a 2FA Application in Infobip. * Lacks idempotency check: Assumes it needs creation if ID is missing. * In production, consider listing apps by name first if API allows. * @returns {Promise<string>} The Application ID. */ async function createApplication() { try { console.log(`Attempting to create Infobip 2FA Application: ${APP_NAME}`); // Note: This doesn't check if an app with this name already exists. // Running this multiple times without saving the ID will create duplicates. const response = await infobipAxios.post('/2fa/2/applications', { name: APP_NAME, configuration: { pinAttempts: 10, // Max attempts to verify PIN allowMultiplePinVerifications: true, // Useful for testing, maybe false in prod pinTimeToLive: '5m', // PIN validity duration (e.g., 5 minutes) verifyPinLimit: '3/1s', // Rate limit for verification calls per second sendPinPerApplicationLimit: '10000/1d', // Max PINs sent per app per day sendPinPerPhoneNumberLimit: '5/1d' // Max PINs sent per phone number per day }, enabled: true }); console.log('Infobip Application created successfully.'); return response.data.applicationId; } catch (error) { console.error('Error creating Infobip Application:', error.response?.data || error.message); // Possible improvement: Check if error indicates a conflict (app already exists) // and attempt to retrieve the existing app's ID. Requires checking Infobip API error codes. throw new Error(`Failed to create Infobip Application: ${error.message}`); } } /** * Creates a 2FA Message Template in Infobip for a given Application. * Lacks idempotency check: Assumes it needs creation if ID is missing. * In production, consider listing templates for the app first. * @param {string} appId The Infobip Application ID. * @returns {Promise<string>} The Message ID. */ async function createMessageTemplate(appId) { try { console.log(`Attempting to create Infobip Message Template for App ID: ${appId}`); // Note: This doesn't check if a template with this configuration already exists for the app. // Running this multiple times without saving the ID will create duplicates. const response = await infobipAxios.post(`/2fa/2/applications/${appId}/messages`, { messageText: MESSAGE_TEMPLATE_TEXT, pinType: 'NUMERIC', // Can be NUMERIC, ALPHA, ALPHANUMERIC, HEX pinLength: 6, // Length of the OTP code language: 'en', // Optional: Specify language senderId: SENDER_ID, // Alphanumeric sender ID (must be registered/approved by Infobip in some regions) // regional: { indiaDlt: { contentTemplateId: '...', principalEntityId: '...' } }, // Example for regional compliance like India DLT }); console.log('Infobip Message Template created successfully.'); return response.data.messageId; } catch (error) { console.error('Error creating Infobip Message Template:', error.response?.data || error.message); // Possible improvement: Check for conflict errors. throw new Error(`Failed to create Infobip Message Template: ${error.message}`); } } // --- Core Service Functions --- /** * Initializes the service: Checks credentials, creates Axios instance, * and ensures Application and Message Template IDs are available, * creating them via API if not found in environment variables. * Relies on manual update of .env after first run. */ async function initialize() { if (!INFOBIP_API_KEY || !INFOBIP_BASE_URL) { throw new Error('Infobip API Key or Base URL missing in environment variables (INFOBIP_API_KEY, INFOBIP_BASE_URL).'); } // Initialize Axios instance now that we have base URL and key infobipAxios = axios.create({ baseURL: INFOBIP_BASE_URL, headers: { 'Authorization': `Bearer ${INFOBIP_API_KEY}`, 'Content-Type': 'application/json', 'Accept': 'application/json' } }); if (!applicationId) { console.log('INFOBIP_APPLICATION_ID not found in .env, attempting to create one...'); applicationId = await createApplication(); // IMPORTANT: Manual step required after first run! console.warn(`------------------------------------------------------------------------------------`); console.warn(`ACTION REQUIRED: Copy the Application ID below and paste it into your .env file as:`); console.warn(`INFOBIP_APPLICATION_ID=${applicationId}`); console.warn(`This avoids creating a new application on every server start.`); console.warn(`------------------------------------------------------------------------------------`); // Consider a dedicated setup script or making these mandatory env vars for subsequent runs. } else { console.log(`Using existing Infobip Application ID from .env: ${applicationId}`); } if (!messageId) { console.log('INFOBIP_MESSAGE_ID not found in .env, attempting to create one...'); messageId = await createMessageTemplate(applicationId); // IMPORTANT: Manual step required after first run! console.warn(`------------------------------------------------------------------------------------`); console.warn(`ACTION REQUIRED: Copy the Message ID below and paste it into your .env file as:`); console.warn(`INFOBIP_MESSAGE_ID=${messageId}`); console.warn(`This avoids creating a new message template on every server start.`); console.warn(`------------------------------------------------------------------------------------`); } else { console.log(`Using existing Infobip Message ID from .env: ${messageId}`); } } /** * Sends an OTP to the specified phone number using the configured App/Message. * @param {string} phoneNumber The recipient's phone number in E.164 format (e.g., 447... ). * @returns {Promise<string>} The PIN ID for verification. */ async function sendOtp(phoneNumber) { if (!applicationId || !messageId || !infobipAxios) { throw new Error('Infobip service not initialized properly. Check configuration and initialization logs.'); } try { const response = await infobipAxios.post('/2fa/2/pin', { applicationId: applicationId, messageId: messageId, // 'from' can often be omitted if set correctly in the Message Template // from: SENDER_ID, to: phoneNumber }); // Response includes pinId, to, ncStatus, smsStatus console.log('Infobip Send PIN Response Status:', response.data.smsStatus); if (!response.data.pinId) { throw new Error('Infobip did not return a pinId in the response.'); } return response.data.pinId; } catch (error) { console.error(`Error sending OTP via Infobip to ${phoneNumber}:`, error.response?.data || error.message); // Rethrow a more specific error or handle based on Infobip error codes throw error; // Let the route handler manage the HTTP response } } /** * Verifies the OTP code submitted by the user against the pinId. * @param {string} pinId The PIN ID received after sending the OTP. * @param {string} otpCode The 6-digit code entered by the user. * @returns {Promise<boolean>} True if verification is successful, false otherwise. */ async function verifyOtp(pinId, otpCode) { if (!applicationId || !messageId || !infobipAxios) { throw new Error('Infobip service not initialized properly. Check configuration and initialization logs.'); } try { const response = await infobipAxios.post(`/2fa/2/pin/${pinId}/verify`, { pin: otpCode }); // Response includes verified (boolean), msisdn, attemptsRemaining console.log(`Infobip Verify PIN Response: Verified=${response.data.verified}, Attempts Left=${response.data.attemptsRemaining}`); return response.data.verified; } catch (error) { console.error(`Error verifying OTP via Infobip for pinId ${pinId}:`, error.response?.data || error.message); // Handle specific errors like PIN expired, not found, etc. if (error.response?.status === 400) { // Common client errors (wrong PIN, expired, max attempts) const serviceException = error.response.data?.requestError?.serviceException; if (serviceException) { console.warn(`Infobip Verification Error: ${serviceException.messageId} - ${serviceException.text}`); // If PIN is incorrect ('PIN_INVALID'), the `verified` flag will be false, caught below. // Re-throw specific errors that the route handler should know about (like expired) if (serviceException.messageId === 'PIN_EXPIRED' || serviceException.messageId === 'PIN_NOT_FOUND' || serviceException.messageId === 'MAX_ATTEMPTS_EXCEEDED') { throw error; // Let route handler provide specific message } } // Treat other 400s (like potentially wrong PIN if not caught above) as simply """"not verified"""" return false; } // Rethrow unexpected errors (e.g., 5xx from Infobip, network errors) for server-level handling throw error; } } module.exports = { initialize, sendOtp, verifyOtp }; -
Create
.envFile: Add your Infobip credentials and configuration here.dotenv# .env # --- Server --- PORT=3000 # --- Infobip Credentials --- # Get these from your Infobip account dashboard (https://portal.infobip.com/) # API Key Management section INFOBIP_API_KEY=YOUR_ACTUAL_INFOBIP_API_KEY INFOBIP_BASE_URL=YOUR_INFOBIP_ACCOUNT_BASE_URL # e.g., https://xyz123.api.infobip.com # --- Infobip 2FA Configuration (Leave blank initially) --- # After the first successful server start, the Application ID and Message ID will be logged # to the console. Copy and paste those values here to prevent recreating them on every run. # This is crucial for consistent behavior and avoiding duplicate resources in Infobip. INFOBIP_APPLICATION_ID= INFOBIP_MESSAGE_ID= # --- Optional Infobip Configuration --- # You can override the defaults in infobip.js by setting these variables # INFOBIP_APP_NAME=""My Custom App Name"" # INFOBIP_SENDER_ID=""MySender"" # Ensure this sender is approved/valid in your region # INFOBIP_MESSAGE_TEMPLATE=""Your code for My Custom App is {{pin}}.""How to find
INFOBIP_API_KEYandINFOBIP_BASE_URL:- Log in to your Infobip account: https://portal.infobip.com/
- Your Base URL is usually visible on the homepage dashboard after logging in, often in an ""API"" section or code examples. It looks like
https://<your-unique-id>.api.infobip.com. - Navigate to the ""API Key Management"" section (often under your account settings or Developer Tools).
- Create a new API key if you don't have one. Copy the key value securely into your
.envfile. Treat this key like a password.
2. Implementing OTP Core Functionality with Infobip
The core logic is implemented within server.js (route handlers) and services/infobip.js (API interaction).
Key Implementation Details:
- Infobip Service Initialization (
initialize):- On server startup, this function checks if
INFOBIP_APPLICATION_IDandINFOBIP_MESSAGE_IDare present in the environment variables (.env). - If not found, it calls the Infobip API to create a new 2FA Application and a Message Template within that application using the settings defined (or defaulted) in
services/infobip.js. - It logs the created IDs with clear instructions (ACTION REQUIRED) prompting you to add them to your
.envfile. This manual step is necessary for this example setup to prevent creating new apps/templates on every restart, which is inefficient, costly, and can clutter your Infobip account. A more advanced setup might use a separate one-time setup script. - Why? Application and Message Template configurations define how OTPs are handled (length, expiry, rate limits, message text). These are typically static configurations for your application's 2FA use case.
- On server startup, this function checks if
- Requesting OTP (
/request-otproute,sendOtpservice function):- The route takes the
phoneNumberfrom the request body. - It calls
infobipService.sendOtp, passing the phone number. - The
sendOtpfunction makes aPOSTrequest to Infobip's/2fa/2/pinendpoint, including theapplicationId,messageId, and recipient number (to). - Infobip handles generating the PIN and sending the SMS based on the template associated with the
messageId. - Infobip returns a
pinIdin the response. ThispinIduniquely identifies this specific OTP request. - Crucially: The
server.jsstores thispinIdtemporarily (in our demootpStore), associating it with thephoneNumber. This in-memory store is NOT SUITABLE FOR PRODUCTION. In a real application, use secure server-side storage like Redis (with TTL), a database table (with TTL and cleanup), or encrypted session data linked to the user attempting verification. This is vital to link the subsequent verification step back to the correct OTP request and user session. The simplesetTimeoutcleanup in the example is also not robust for production.
- The route takes the
- Verifying OTP (
/verify-otproute,verifyOtpservice function):- The route takes
phoneNumberand the submittedotpcode. - It retrieves the
pinIdpreviously stored for thatphoneNumberfrom the temporary store. If nopinIdis found (e.g., expired, cleaned up, never requested, or server restarted with in-memory store), it returns an error. - It calls
infobipService.verifyOtp, passing the retrievedpinIdand the user's submittedotpcode. - The
verifyOtpfunction makes aPOSTrequest to Infobip's/2fa/2/pin/{pinId}/verifyendpoint. - Infobip checks if the provided
otpmatches the one generated for thatpinIdand if it's still valid (not expired, within attempt limits defined in the Application config). - Infobip returns a response including a
verifiedboolean flag. - If
verifiedis true, the route clears the storedpinId(it's been successfully used) and returns success. This is where you would typically update the user's status in your database (e.g., markis_phone_verified = true). - If
verifiedis false or an error occurs (like PIN expired, which the service function identifies and potentially re-throws), it returns an appropriate error message.
- The route takes
Alternative Approaches:
- Infobip SDKs: Infobip provides official SDKs for various languages (check their developer portal). These can abstract some of the direct
axioscalls and might offer convenience features. However, understanding the underlying API calls remains beneficial. - State Management: As emphasized above, replace the demo
otpStore. Redis with key expiry is a common, performant choice. A database table with columns forpinId,phoneNumber(oruserId),expiresAt, andisVerifiedis another solid option, requiring a background job or query condition to handle cleanup of expired entries.
3. Building Production-Ready API Endpoints
Our server.js already defines the basic API layer. Let's discuss enhancements for production.
- Authentication/Authorization:
- The
/request-otpendpoint might be public (e.g., during phone verification in registration) or require user authentication (e.g., if adding/verifying a phone for an existing logged-in user). Implement checks accordingly. - The
/verify-otpendpoint implicitly relies on thepinIdbeing linked to the correct user session or initial request via your chosen state management. Ensure this link is secure and cannot be manipulated. If verifying a logged-in user, double-check the verification action is authorized for that specific user's session.
- The
- Request Validation: Prevent invalid or malicious data from hitting your core logic or the Infobip API.
- Example using
express-validator:bashnpm install express-validatorjavascript// server.js (additions/modifications shown) const { body, validationResult } = require('express-validator'); const express = require('express'); // Ensure express is required if not already const infobipService = require('./services/infobip'); require('dotenv').config(); const app = express(); // Assuming app setup is here or imported const PORT = process.env.PORT || 3000; app.use(express.json()); // --- In-Memory Storage (DEMO ONLY) --- const otpStore = {}; const setupOtpCleanup = (phoneNumber, pinId) => { setTimeout(() => { if (otpStore[phoneNumber] === pinId) { delete otpStore[phoneNumber]; console.log(`Cleaned up expired/unused pinId for ${phoneNumber}`); } }, 10 * 60 * 1000); // 10 minutes }; // --- Validation Middleware --- const validatePhoneNumber = [ body('phoneNumber') .trim() .notEmpty().withMessage('Phone number is required.') .isMobilePhone('any', { strictMode: false }).withMessage('Invalid phone number format. Include country code if necessary.'), // Consider adding custom validation for E.164 format if needed (using libphonenumber-js) ]; const validateVerification = [ body('phoneNumber') .trim() .notEmpty().withMessage('Phone number is required.') .isMobilePhone('any', { strictMode: false }).withMessage('Invalid phone number format.'), body('otp') .trim() .notEmpty().withMessage('OTP is required.') .isLength({ min: 6, max: 6 }).withMessage('OTP must be exactly 6 digits.') .isNumeric().withMessage('OTP must contain only numbers.') ]; // --- Helper to handle validation results --- const handleValidationErrors = (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ success: false, errors: errors.array() }); } next(); }; // --- Routes (with validation added) --- app.post('/request-otp', validatePhoneNumber, handleValidationErrors, async (req, res) => { // ... (existing route logic) const { phoneNumber } = req.body; // Already validated try { console.log(`Requesting OTP for ${phoneNumber}`); const pinId = await infobipService.sendOtp(phoneNumber); console.log(`OTP request successful for ${phoneNumber}, pinId: ${pinId}`); otpStore[phoneNumber] = pinId; setupOtpCleanup(phoneNumber, pinId); res.status(200).json({ success: true, message: 'OTP sent successfully.' }); } catch (error) { // ... (existing error handling) console.error('Error sending OTP:', error.response?.data || error.message); res.status(500).json({ success: false, error: 'Failed to send OTP. Please try again later.', errorCode: error.response?.data?.requestError?.serviceException?.messageId || 'UNKNOWN_ERROR' }); } }); app.post('/verify-otp', validateVerification, handleValidationErrors, async (req, res) => { // ... (existing route logic) const { phoneNumber, otp } = req.body; // Already validated const pinId = otpStore[phoneNumber]; if (!pinId) { // ... (existing handling for missing pinId) console.warn(`No active OTP request found for ${phoneNumber}.`); return res.status(400).json({ success: false, error: 'No active OTP request found or it has expired. Please request a new OTP.' }); } try { // ... (existing verification logic) console.log(`Verifying OTP ${otp} for ${phoneNumber} with pinId ${pinId}`); const isVerified = await infobipService.verifyOtp(pinId, otp); if (isVerified) { // ... (existing success handling) console.log(`OTP verification successful for ${phoneNumber}`); delete otpStore[phoneNumber]; res.status(200).json({ success: true, message: 'OTP verified successfully.' }); } else { // ... (existing failure handling) console.warn(`OTP verification failed for ${phoneNumber}. Incorrect PIN.`); res.status(400).json({ success: false, error: 'Invalid OTP code.' }); } } catch (error) { // ... (existing error handling, including specific error codes) console.error('Error verifying OTP:', error.response?.data || error.message); const errorCode = error.response?.data?.requestError?.serviceException?.messageId; let errorMessage = 'Failed to verify OTP. Please try again later.'; // ... (logic for specific error messages based on errorCode) if (errorCode === 'TOO_MANY_REQUESTS') { errorMessage = 'Too many verification attempts. Please try again later.'; } else if (errorCode === 'PIN_EXPIRED') { errorMessage = 'OTP code has expired. Please request a new one.'; if (otpStore[phoneNumber] === pinId) delete otpStore[phoneNumber]; } else if (errorCode === 'PIN_NOT_FOUND') { errorMessage = 'Invalid request. Please request a new OTP.'; if (otpStore[phoneNumber] === pinId) delete otpStore[phoneNumber]; } res.status(500).json({ success: false, error: errorMessage, errorCode: errorCode || 'UNKNOWN_ERROR' }); } }); // --- Start Server --- app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); infobipService.initialize() .then(() => console.log('Infobip service initialized.')) .catch(err => console.error('Failed to initialize Infobip service:', err)); });
- Example using
Redis Implementation:
To use Redis for production-ready storage with TTL (time-to-live) for 5-minute expiry:
-
Install Redis:
bashnpm install redis -
Create Redis Client:
javascript// server.js const redis = require('redis'); const client = redis.createClient({ host: 'localhost', // or your Redis server address port: 6379, // default Redis port password: 'your_redis_password' // if you have one }); // Handle Redis connection events client.on('connect', () => console.log('Connected to Redis')); client.on('error', (err) => console.error('Redis Error:', err)); -
Store and Retrieve OTPs:
javascript// server.js const storeOtp = (phoneNumber, pinId) => { client.setex(phoneNumber, 300, pinId); // 300 seconds = 5 minutes }; const getOtp = (phoneNumber, callback) => { client.get(phoneNumber, (err, pinId) => { if (err) { callback(err); } else { callback(null, pinId); } }); }; const deleteOtp = (phoneNumber) => { client.del(phoneNumber); }; -
Update Routes to Use Redis:
javascript// server.js app.post('/request-otp', async (req, res) => { const { phoneNumber } = req.body; if (!phoneNumber) { return res.status(400).json({ success: false, error: 'Phone number is required' }); } try { console.log(`Requesting OTP for ${phoneNumber}`); const pinId = await infobipService.sendOtp(phoneNumber); console.log(`OTP request successful for ${phoneNumber}, pinId: ${pinId}`); storeOtp(phoneNumber, pinId); res.status(200).json({ success: true, message: 'OTP sent successfully.' }); } catch (error) { console.error('Error sending OTP:', error.response?.data || error.message); // Provide a generic error message to the client res.status(500).json({ success: false, error: 'Failed to send OTP. Please try again later.', // Optionally include details in logs or specific non-sensitive error codes errorCode: error.response?.data?.requestError?.serviceException?.messageId || 'UNKNOWN_ERROR' }); } }); app.post('/verify-otp', async (req, res) => { const { phoneNumber, otp } = req.body; if (!phoneNumber || !otp) { return res.status(400).json({ success: false, error: 'Phone number and OTP are required' }); } getOtp(phoneNumber, (err, pinId) => { if (err) { console.error('Error retrieving OTP from Redis:', err); return res.status(500).json({ success: false, error: 'Failed to verify OTP. Please try again later.', errorCode: 'REDIS_ERROR' }); } if (!pinId) { console.warn(`No active OTP request found for ${phoneNumber}. It might have expired or never existed.`); return res.status(400).json({ success: false, error: 'No active OTP request found or it has expired. Please request a new OTP.' }); } try { console.log(`Verifying OTP ${otp} for ${phoneNumber} with pinId ${pinId}`); const isVerified = await infobipService.verifyOtp(pinId, otp); if (isVerified) { console.log(`OTP verification successful for ${phoneNumber}`); deleteOtp(phoneNumber); res.status(200).json({ success: true, message: 'OTP verified successfully.' }); // TODO: Update user status in your database (e.g., mark phone as verified) } else { console.warn(`OTP verification failed for ${phoneNumber}. Incorrect PIN.`); // Verification failed (likely incorrect PIN) // NOTE: Infobip handles attempt limits based on Application settings res.status(400).json({ success: false, error: 'Invalid OTP code.' }); // Consider if you want to clear pinId from store on failure based on app logic } } catch (error) { console.error('Error verifying OTP:', error.response?.data || error.message); // Check for specific Infobip errors if needed const errorCode = error.response?.data?.requestError?.serviceException?.messageId; let errorMessage = 'Failed to verify OTP. Please try again later.'; if (errorCode === 'TOO_MANY_REQUESTS') { errorMessage = 'Too many verification attempts. Please try again later.'; } else if (errorCode === 'PIN_EXPIRED') { errorMessage = 'OTP code has expired. Please request a new one.'; // Clean up expired pin from store if it matches the one we tried to verify if (otpStore[phoneNumber] === pinId) delete otpStore[phoneNumber]; } else if (errorCode === 'PIN_NOT_FOUND') { errorMessage = 'Invalid request. Please request a new OTP.'; // Clean up potentially invalid pin from store if it matches if (otpStore[phoneNumber] === pinId) delete otpStore[phoneNumber]; } res.status(500).json({ success: false, error: errorMessage, errorCode: errorCode || 'UNKNOWN_ERROR' }); } }); });
4. SMS OTP Security Best Practices
Beyond the built-in Infobip rate limiting, implement these critical security measures:
-
Application-Level Rate Limiting: Install
express-rate-limitto prevent abuse:bashnpm install express-rate-limitjavascriptconst rateLimit = require('express-rate-limit'); const otpRequestLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 3, // Limit each phone number to 3 OTP requests per window message: { success: false, error: 'Too many OTP requests. Please try again later.' }, keyGenerator: (req) => req.body.phoneNumber // Rate limit by phone number }); app.post('/request-otp', otpRequestLimiter, async (req, res) => { // ... existing logic }); -
NIST Guidelines Compliance: According to NIST SP 800-63B (Digital Identity Guidelines, revised June 2017), OTP implementations should:
- Use at least 6 random digits (implemented:
pinLength: 6) - Expire within 10 minutes or less (implemented:
pinTimeToLive: '5m') - Limit verification attempts to prevent brute force (implemented:
pinAttempts: 10) - Use secure channels (SMS over TLS/HTTPS)
- Use at least 6 random digits (implemented:
-
Phone Number Validation: Use
libphonenumber-jsfor robust E.164 validation:bashnpm install libphonenumber-jsjavascriptconst { parsePhoneNumber } = require('libphonenumber-js'); function validatePhoneNumber(phoneNumber) { try { const parsedNumber = parsePhoneNumber(phoneNumber); return parsedNumber.isValid() ? parsedNumber.number : null; // Returns E.164 format } catch (error) { return null; } } -
HTTPS Only: Always use HTTPS in production to prevent OTP interception. Configure your reverse proxy (nginx, Apache) or hosting platform to enforce HTTPS.
-
Session Binding: Associate OTP verification with user sessions to prevent OTP reuse attacks. Store session IDs alongside pinIds in your storage layer.
5. SMS Compliance and International Regulations
Different countries have specific regulations for SMS messaging that you must comply with:
-
India DLT (Distributed Ledger Technology): Required as of March 2021. You must register your content templates and Principal Entity with telecom operators. Add the DLT parameters to your message template:
javascript// In services/infobip.js, createMessageTemplate function const response = await infobipAxios.post(`/2fa/2/applications/${appId}/messages`, { messageText: MESSAGE_TEMPLATE_TEXT, pinType: 'NUMERIC', pinLength: 6, language: 'en', senderId: SENDER_ID, regional: { indiaDlt: { contentTemplateId: process.env.INDIA_DLT_CONTENT_TEMPLATE_ID, principalEntityId: process.env.INDIA_DLT_PRINCIPAL_ENTITY_ID } } });Source: TRAI DLT Registration Portal
-
United States A2P 10DLC: For application-to-person messaging, register your brand and campaign with The Campaign Registry (TCR). Effective August 2021, unregistered traffic faces filtering. Registration costs $4-$15 monthly depending on throughput needs.
-
European Union GDPR: When processing phone numbers, ensure you have explicit user consent, provide data processing agreements, and allow users to delete their data. Store OTP-related data with appropriate retention policies (typically 90 days maximum).
-
Sender ID Registration: Alphanumeric sender IDs (e.g., "YourBrand") require pre-registration in many countries including UAE, Saudi Arabia, Singapore, and Australia. Use numeric sender IDs or short codes as alternatives where alphanumeric IDs are not supported.
-
Do Not Disturb (DND) Registries: Respect DND preferences in countries that maintain them (India, Singapore, UAE). Infobip typically handles filtering, but verify your account configuration.
Source: Infobip Regulatory Requirements (Last updated: Q4 2024)
6. Testing and Monitoring OTP Delivery
Testing Strategies
-
Development Testing with Infobip Trial Account:
- Trial accounts have a whitelist of approved phone numbers (typically 1-5 numbers)
- Add test numbers in Infobip Portal under Account Settings > Test Numbers
- Trial limitations: Usually 100-200 free SMS messages, restricted delivery destinations
-
Unit Testing with Mocks:
javascript// __tests__/infobip.test.js const infobipService = require('../services/infobip'); const axios = require('axios'); jest.mock('axios'); describe('Infobip Service', () => { test('sendOtp returns pinId on success', async () => { axios.create.mockReturnValue({ post: jest.fn().mockResolvedValue({ data: { pinId: 'test-pin-123', smsStatus: 'MESSAGE_SENT' } }) }); const pinId = await infobipService.sendOtp('+447700900000'); expect(pinId).toBe('test-pin-123'); }); }); -
Integration Testing:
- Use a dedicated test phone number for automated integration tests
- Implement SMS retrieval via Infobip webhooks or a service like Twilio's Verify API for automated OTP extraction
- Run tests in a staging environment with production-equivalent configuration
Production Monitoring
Monitor these critical metrics to ensure system health and security:
-
OTP Delivery Metrics:
- Success rate: Target >98% for delivered messages
- Average delivery time: Should be <30 seconds
- Failure reasons: Track by error code (invalid number, carrier rejection, etc.)
- Cost per OTP: Monitor to detect anomalies or abuse
-
Security Metrics:
- Failed verification attempts: Alert on >5 failures per phone number per hour
- Unusual request patterns: Alert on sudden spikes in OTP requests
- Expired OTP rate: High rates may indicate UX issues or delivery delays
- Time between request and verification: Unusual patterns may indicate fraud
-
Implementation with Logging:
javascript// server.js - Add structured logging const winston = require('winston'); const logger = winston.createLogger({ level: 'info', format: winston.format.json(), transports: [ new winston.transports.File({ filename: 'otp-events.log' }) ] }); // Log OTP events logger.info('otp_requested', { phoneNumber, pinId, timestamp: new Date().toISOString() }); logger.info('otp_verified', { phoneNumber, success: isVerified, attemptNumber: 1, timestamp: new Date().toISOString() }); -
Alerting Rules:
- Alert when OTP success rate drops below 95% over 15 minutes
- Alert when any phone number requests >10 OTPs in 1 hour
- Alert when verification failure rate exceeds 30% over 1 hour
- Alert when Infobip API returns 5xx errors
-
Health Check Endpoint:
javascriptapp.get('/health', async (req, res) => { try { // Check Redis connection if using Redis await client.ping(); // Optional: Verify Infobip API accessibility const infobipHealthy = await infobipService.healthCheck(); res.status(200).json({ status: 'healthy', redis: 'connected', infobip: infobipHealthy ? 'connected' : 'degraded', timestamp: new Date().toISOString() }); } catch (error) { res.status(503).json({ status: 'unhealthy', error: error.message }); } });
Source: Infobip SMS Analytics for detailed delivery reporting