code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

Implementing SMS OTP Authentication with Infobip in Node.js and Express

Complete guide to building SMS-based two-factor authentication using Infobip 2FA API, Node.js, and Express with rate limiting and security best practices.

Two-Factor Authentication (2FA) adds a crucial layer of security to user accounts by requiring a second form of verification beyond just a password. One of the most common 2FA methods is One-Time Passwords (OTP) delivered via SMS.

This guide shows you how to implement SMS-based OTP verification in a Node.js application using the Express framework and the Infobip 2FA API. You'll build a simple Express application with endpoints to request an OTP via SMS and verify the OTP entered by the user.

What You'll Learn:

  • How to implement SMS OTP authentication in Node.js with Express
  • Integrate Infobip's 2FA API for sending and verifying one-time passwords
  • Build secure OTP workflows with rate limiting and session management
  • Handle OTP verification errors and expiration scenarios
  • Deploy production-ready SMS authentication to your application

Project Goals:

  • Secure user actions (like login or registration) with SMS-based OTP.
  • Integrate with Infobip's 2FA API for sending and verifying OTPs.
  • Build a robust Node.js/Express backend to handle the OTP lifecycle.
  • Implement essential security measures like rate limiting.
  • Provide clear steps for setup, implementation, testing, and deployment.

Technologies Used:

  • Node.js: JavaScript runtime environment.
  • Express: Minimalist web framework for Node.js.
  • Infobip 2FA API: Service for sending and verifying OTPs via SMS, Voice, or Email. This guide focuses on SMS.
  • axios: Promise-based HTTP client for making requests to the Infobip API.
  • dotenv: Module to load environment variables from a .env file.
  • express-rate-limit: Middleware for rate-limiting requests in Express.
  • (Optional) Database: For persisting user data and potentially linking pinId. This guide uses a simple in-memory store for demonstration, but provides schema guidance for a real database.

System Architecture:

mermaid
sequenceDiagram
    participant User
    participant Browser/Client
    participant ExpressApp as Node.js/Express App
    participant InfobipAPI as Infobip 2FA API
    participant SMSGateway as SMS Gateway

    User->>Browser/Client: Initiates action requiring OTP (e.g., Login)
    Browser/Client->>+ExpressApp: POST /api/send-otp (phoneNumber)
    ExpressApp->>+InfobipAPI: Send PIN Request (apiKey, appId, msgId, phoneNumber)
    InfobipAPI->>+SMSGateway: Send SMS with OTP
    SMSGateway->>User: Delivers SMS with OTP
    InfobipAPI-->>-ExpressApp: Success Response (pinId)
    Note over ExpressApp: Store pinId securely server-side (e.g., session, temp DB record) associated with user/phone number. DO NOT expose to client.
    ExpressApp-->>-Browser/Client: Success Response ({ message: 'OTP sent successfully.' })
    User->>Browser/Client: Enters received OTP
    Browser/Client->>+ExpressApp: POST /api/verify-otp (phoneNumber, otpCode)
    Note over ExpressApp: Retrieve stored pinId associated with user/phone number from server-side store.
    ExpressApp->>+InfobipAPI: Verify PIN Request (apiKey, pinId, otpCode)
    InfobipAPI-->>-ExpressApp: Verification Response (verified: true/false)
    alt Verification Successful
        ExpressApp->>ExpressApp: Mark user/action as verified
        ExpressApp-->>-Browser/Client: Success Response ({ verified: true, message: 'OTP verified successfully.' })
    else Verification Failed
        ExpressApp-->>-Browser/Client: Failure Response ({ verified: false, error: 'Invalid or expired OTP.' })
    end

Prerequisites:

  • Install Node.js and npm (or yarn).
  • Create a free trial account at Infobip.
  • Understand the basics of Node.js, Express, REST APIs, and asynchronous JavaScript.
  • Use a tool for testing APIs (like Postman or curl).

1. Setting Up Your Node.js OTP Project

Initialize your Node.js project and install the necessary dependencies for SMS authentication.

1.1 Create Project Directory

Open your terminal and create a new directory for the project:

bash
mkdir node-infobip-otp
cd node-infobip-otp

1.2 Initialize npm

bash
npm init -y

This creates a package.json file.

1.3 Install Dependencies

bash
npm install express dotenv axios express-rate-limit
  • express: The web framework.
  • dotenv: Manages environment variables for API keys and configurations.
  • axios: Makes HTTP requests to the Infobip API.
  • express-rate-limit: Prevents abuse of OTP endpoints.

1.4 Project Structure

Create the following basic structure:

node-infobip-otp/ ├── node_modules/ ├── .env # Stores environment variables (API keys, etc.) - DO NOT COMMIT ├── .gitignore # Specifies intentionally untracked files that Git should ignore ├── server.js # Main application file ├── infobipService.js # Handles interaction with Infobip API ├── package.json └── package-lock.json

1.5 Configure .gitignore

Create a .gitignore file in the root directory and add node_modules and .env to prevent committing them to version control:

# .gitignore node_modules .env

1.6 Create .env File

Create a .env file in the root directory. Populate this with your Infobip credentials and configuration obtained in Section 4.

dotenv
# .env - Replace placeholder values with your actual credentials.
# See Section 4 of the guide for details on obtaining these values.

# Infobip Credentials (See Section 4.1)
INFOBIP_BASE_URL=YOUR_INFOBIP_BASE_URL # Found on Infobip portal homepage/API section
INFOBIP_API_KEY=YOUR_INFOBIP_API_KEY   # Generated in Infobip portal API Key management

# Infobip 2FA Configuration (See Section 4.2)
INFOBIP_2FA_APP_ID=YOUR_INFOBIP_2FA_APP_ID # ID from created 2FA Application
INFOBIP_2FA_MSG_ID=YOUR_INFOBIP_2FA_MSG_ID # ID from created 2FA Message Template

# Application Port
PORT=3000

Purpose of Configuration: Using environment variables (dotenv) is crucial for security and flexibility. It keeps sensitive credentials like API keys out of your source code and allows different configurations for development, staging, and production environments.

2. Building the Infobip SMS OTP Service

Create functions to interact with the Infobip 2FA API: sending the OTP and verifying it. Encapsulating third-party API interactions follows best practices.

Create a new file, infobipService.js:

javascript
// infobipService.js
const axios = require('axios');

// Ensure environment variables are loaded (usually done in server.js, but safe to re-require)
require('dotenv').config();

const INFOBIP_BASE_URL = process.env.INFOBIP_BASE_URL;
const INFOBIP_API_KEY = process.env.INFOBIP_API_KEY;
const INFOBIP_2FA_APP_ID = process.env.INFOBIP_2FA_APP_ID;
const INFOBIP_2FA_MSG_ID = process.env.INFOBIP_2FA_MSG_ID;

// Configure Axios instance for Infobip API calls
const infobipAxios = axios.create({
    baseURL: INFOBIP_BASE_URL,
    headers: {
        'Authorization': `App ${INFOBIP_API_KEY}`,
        'Content-Type': 'application/json',
        'Accept': 'application/json',
    }
});

/**
 * Sends an OTP PIN via SMS using Infobip 2FA API.
 * @param {string} phoneNumber - The recipient's phone number in E.164 format.
 * @returns {Promise<string>} - A promise that resolves with the pinId.
 * @throws {Error} - Throws an error if the API call fails or configuration is missing.
 */
async function sendOtp(phoneNumber) {
    if (!INFOBIP_BASE_URL || !INFOBIP_API_KEY || !INFOBIP_2FA_APP_ID || !INFOBIP_2FA_MSG_ID) {
         console.error('Infobip configuration is missing or incomplete in .env file.');
         throw new Error('Server configuration error: Infobip details missing.');
         // In a real app, you might have more robust config validation on startup.
         // See Section 4 for obtaining these values.
    }

    try {
        console.log(`Sending OTP to ${phoneNumber} via Infobip...`);
        const response = await infobipAxios.post(`/2fa/2/pin`, {
            applicationId: INFOBIP_2FA_APP_ID,
            messageId: INFOBIP_2FA_MSG_ID,
            to: phoneNumber,
            // 'from' can often be omitted if set in the message template or app config
            // from: 'YourAppName'
        });

        if (response.data && response.data.pinId) {
            console.log(`OTP sent successfully. Pin ID: ${response.data.pinId}`); // Log pinId server-side for debugging
            return response.data.pinId; // Return pinId for server-side use
        } else {
            console.error('Infobip send OTP response missing pinId:', response.data);
            throw new Error('Failed to send OTP: Invalid response from Infobip.');
        }
    } catch (error) {
        const errorMessage = error.response?.data?.requestError?.serviceException?.text || error.message || 'Failed to send OTP via Infobip.';
        console.error(`Error sending Infobip OTP to ${phoneNumber}:`, errorMessage, error.response?.data || '');
        // Re-throw a more generic error or the specific Infobip text
        throw new Error(error.response?.data?.requestError?.serviceException?.text || 'Failed to send OTP via Infobip.');
    }
}

/**
 * Verifies an OTP PIN using Infobip 2FA API.
 * @param {string} pinId - The ID of the PIN received when sending the OTP (kept server-side).
 * @param {string} otpCode - The OTP code entered by the user.
 * @returns {Promise<boolean>} - A promise that resolves with true if verified, false otherwise.
 * @throws {Error} - Throws an error if the API call fails unexpectedly (not for standard verification failures like wrong pin).
 */
async function verifyOtp(pinId, otpCode) {
    if (!pinId || !otpCode) {
        throw new Error('Missing pinId or otpCode for verification.');
    }
     if (!INFOBIP_BASE_URL || !INFOBIP_API_KEY) {
         console.error('Infobip configuration is missing or incomplete in .env file.');
         throw new Error('Server configuration error: Infobip details missing.');
     }

    try {
        console.log(`Verifying OTP for Pin ID: ${pinId}`);
        const response = await infobipAxios.post(`/2fa/2/pin/${pinId}/verify`, {
            pin: otpCode
        });

        // Check if 'verified' field exists in the response data
        if (response.data && typeof response.data.verified !== 'undefined') {
            console.log(`OTP Verification result for ${pinId}: ${response.data.verified}`);
            return response.data.verified; // Returns true or false based on Infobip's check
        } else {
            // Handle cases where verification fails gracefully (e.g., wrong PIN)
            // Infobip API should return verified: false in the response data for known failures.
            // If the structure is unexpected, log an error.
            console.error('Infobip verify OTP response missing expected structure:', response.data);
            throw new Error('Failed to verify OTP: Invalid response structure from Infobip.');
        }
    } catch (error) {
        // Check if the error is an expected verification failure (like WRONG_PIN, TOO_MANY_ATTEMPTS)
        const serviceException = error.response?.data?.requestError?.serviceException;
        if (serviceException && (serviceException.messageId === 'WRONG_PIN' || serviceException.messageId === 'PIN_EXPIRED' || serviceException.messageId === 'TOO_MANY_ATTEMPTS')) {
             console.warn(`Verification failed for ${pinId}: ${serviceException.text}`);
            return false; // Return false for these expected verification failures
        }

        // For other unexpected errors (network, config, other Infobip errors), log and throw
        const errorMessage = serviceException?.text || error.message || 'Failed to verify OTP via Infobip.';
        console.error(`Error verifying Infobip OTP for pinId ${pinId}:`, errorMessage, error.response?.data || '');
        throw new Error(serviceException?.text || 'Failed to verify OTP via Infobip.');
    }
}

module.exports = {
    sendOtp,
    verifyOtp
};

Explanation:

  • We import axios and load necessary environment variables using dotenv.config().
  • An axios instance (infobipAxios) is created with the base URL and default headers (Authorization, Content-Type, Accept) required by Infobip. The API key is included in the Authorization header.
  • sendOtp:
    • Takes the phoneNumber as input.
    • Includes checks for necessary environment variables.
    • Makes a POST request to Infobip's /2fa/2/pin endpoint.
    • Requires applicationId, messageId, and to (phone number).
    • Returns the pinId from the response. This pinId must be kept server-side and associated securely with the user's session or verification attempt.
    • Includes improved error handling and logs relevant information.
  • verifyOtp:
    • Takes the pinId (retrieved from server-side storage) and the otpCode entered by the user.
    • Includes checks for necessary environment variables.
    • Makes a POST request to Infobip's /2fa/2/pin/{pinId}/verify endpoint.
    • Sends the pin (user's code) in the request body.
    • Returns true if response.data.verified is true.
    • Crucially, it catches specific Infobip errors like WRONG_PIN, PIN_EXPIRED, TOO_MANY_ATTEMPTS and returns false instead of throwing an error, as these are expected verification outcomes.
    • Throws an error only for unexpected issues (network errors, configuration problems, other API errors).

3. Creating Express API Endpoints for OTP Verification

Set up the Express server and create the API endpoints for sending and verifying SMS codes.

Update server.js:

javascript
// server.js
require('dotenv').config(); // Load environment variables from .env file
const express = require('express');
const rateLimit = require('express-rate-limit');
const helmet = require('helmet'); // Basic security headers
const { sendOtp, verifyOtp } = require('./infobipService');

const app = express();
const port = process.env.PORT || 3000;

// --- Middleware ---

// Basic security headers
app.use(helmet());

// Enable JSON body parsing
app.use(express.json());

// --- Rate Limiting ---
// Apply rate limiting to OTP endpoints to prevent abuse
const otpSendLimiter = rateLimit({
    windowMs: 5 * 60 * 1000, // 5 minutes
    max: 5, // Limit each IP to 5 OTP send requests per windowMs
    message: { error: 'Too many OTP requests from this IP, please try again after 5 minutes' },
    standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
    legacyHeaders: false, // Disable the `X-RateLimit-*` headers
});

const otpVerifyLimiter = rateLimit({
	windowMs: 15 * 60 * 1000, // 15 minutes
	max: 10, // Limit each IP to 10 verification attempts per windowMs
	message: { error: 'Too many verification attempts from this IP, please try again after 15 minutes' },
	standardHeaders: true,
	legacyHeaders: false,
});

// --- Temporary Storage (Replace with Database/Session in Production) ---
// WARNING: In-memory storage is NOT suitable for production. It's not scalable
// and data is lost on restart. Use a database (like Redis, PostgreSQL, MongoDB)
// or proper session management (e.g., express-session with a persistent store).
const otpStore = {}; // Store { phoneNumber: { pinId: '...', timestamp: ... } }

// --- Secure Storage Association (Conceptual - using express-session) ---
/*
// If using express-session:
const session = require('express-session');
// Configure session middleware with a secure secret and persistent store (e.g., connect-redis)
app.use(session({
  secret: process.env.SESSION_SECRET, // MUST be a strong, random secret stored in .env
  resave: false,
  saveUninitialized: false,
  // store: new RedisStore({ client: redisClient }), // Example using Redis
  cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, maxAge: 15 * 60 * 1000 } // 15 min
}));

// Inside /api/send-otp, after getting pinId:
// req.session.otpPinId = pinId;
// req.session.otpPhoneNumber = phoneNumber; // Store phone number for association
// req.session.otpTimestamp = Date.now();

// Inside /api/verify-otp:
// const pinId = req.session.otpPinId;
// const associatedPhoneNumber = req.session.otpPhoneNumber;
// const timestamp = req.session.otpTimestamp;
// // Validate timestamp, ensure associatedPhoneNumber matches req.body.phoneNumber
// // ... proceed with verifyOtp(pinId, otpCode) ...
// delete req.session.otpPinId; // Clean up session on success/failure/expiry
// delete req.session.otpPhoneNumber;
// delete req.session.otpTimestamp;
*/


// --- API Routes ---

// Endpoint to request an OTP
app.post('/api/send-otp', otpSendLimiter, async (req, res) => {
    const { phoneNumber } = req.body;

    // Input validation (Essential!)
    // Use a robust library like libphonenumber-js for production (See Section 8)
    if (!phoneNumber || !/^\+?[1-9]\d{1,14}$/.test(phoneNumber)) { // Basic E.164-like format check
        return res.status(400).json({ error: 'Valid phone number in E.164 format (e.g., +14155552671) is required.' });
    }

    try {
        const pinId = await sendOtp(phoneNumber);

        // Securely store the pinId server-side, associated with the phone number/user session.
        // DO NOT send pinId back to the client.
        // Using temporary in-memory store for demonstration ONLY:
        otpStore[phoneNumber] = { pinId: pinId, timestamp: Date.now() };
        console.log(`Stored OTP info for ${phoneNumber}:`, otpStore[phoneNumber]); // For debugging

        // Consider using req.session as described above for a more robust approach.

        res.status(200).json({ message: 'OTP sent successfully.' }); // Do NOT return pinId

    } catch (error) {
        console.error(`Error in /api/send-otp route for ${phoneNumber}:`, error.message);
        // Return a generic error message to the client
        res.status(500).json({ error: error.message || 'Failed to send OTP. Please try again later.' });
    }
});

// Endpoint to verify an OTP
app.post('/api/verify-otp', otpVerifyLimiter, async (req, res) => {
    const { phoneNumber, otpCode } = req.body;

    // Basic validation
    if (!phoneNumber || !otpCode || !/^\d{4,8}$/.test(otpCode)) { // Adjust regex based on your PIN length
        return res.status(400).json({ error: 'Phone number and a valid numeric OTP code are required.' });
    }
    // Re-validate phone number format if needed
    if (!/^\+?[1-9]\d{1,14}$/.test(phoneNumber)) {
         return res.status(400).json({ error: 'Valid phone number in E.164 format is required.' });
    }


    // Retrieve the stored pinId securely from server-side storage
    // Using temporary in-memory store for demonstration ONLY:
    const storedOtpData = otpStore[phoneNumber];

    // If using sessions: const pinId = req.session.otpPinId; (plus other checks)

    if (!storedOtpData) {
        return res.status(400).json({ verified: false, error: 'No OTP request found for this number or it may have expired. Please request a new OTP.' });
    }

    // Optional: Check timestamp for expiry (Infobip handles expiry too via pinTimeToLive)
    const otpRequestTime = storedOtpData.timestamp;
    const fifteenMinutes = 15 * 60 * 1000; // Example: 15 minute validity window on our side
    if (Date.now() - otpRequestTime > fifteenMinutes) {
         delete otpStore[phoneNumber]; // Clean up expired entry
         return res.status(400).json({ verified: false, error: 'OTP has expired. Please request a new one.' });
    }

    const pinId = storedOtpData.pinId; // Get pinId from storage

    try {
        const isVerified = await verifyOtp(pinId, otpCode);

        if (isVerified) {
            // OTP is correct - Proceed with user action (e.g., complete login, grant access)
            delete otpStore[phoneNumber]; // Clean up storage after successful verification
            // If using sessions: delete req.session.otpPinId; etc.

            // In a real app: Mark user as verified in your DB/session, complete login, etc.
            res.status(200).json({ verified: true, message: 'OTP verified successfully.' });
        } else {
            // OTP is incorrect or expired on Infobip's side (verifyOtp returns false for these)
             // Optional: Implement attempt counting here or rely on Infobip's limits configured in the Application
            res.status(400).json({ verified: false, error: 'Invalid or expired OTP code.' });
            // Consider deleting from otpStore here too, or after max attempts are reached
        }
    } catch (error) {
        // This catch block handles unexpected errors from verifyOtp (e.g., network, config errors)
        console.error(`Error in /api/verify-otp route for ${phoneNumber} (PinID: ${pinId}):`, error.message);
        // Don't expose detailed internal errors unless necessary
        res.status(500).json({ verified: false, error: 'Failed to verify OTP due to a server error. Please try again later.' });
    }
});

// Basic root route
app.get('/', (req, res) => {
    res.send('Infobip OTP Service is running!');
});

// Global error handler (optional basic example)
app.use((err, req, res, next) => {
  console.error('Unhandled error:', err.stack);
  res.status(500).send({ error: 'Something went wrong!' });
});


// --- Start Server ---
const server = app.listen(port, () => {
    console.log(`Server listening on port ${port}`);
    console.log(`Ensure your .env file is configured correctly (see Section 1.6 & 4).`);
    if (!process.env.INFOBIP_BASE_URL || !process.env.INFOBIP_API_KEY || !process.env.INFOBIP_2FA_APP_ID || !process.env.INFOBIP_2FA_MSG_ID) {
        console.warn('WARNING: Infobip environment variables are not fully set! OTP functionality will likely fail.');
    }
});

// Export app for testing purposes (See Section 13)
module.exports = app;

Explanation:

  1. Middleware: helmet() adds basic security headers. express.json() parses JSON bodies.
  2. Rate Limiting: Separate express-rate-limit instances (otpSendLimiter, otpVerifyLimiter) are applied to the respective routes. This is crucial for security. Adjust limits as needed.
  3. Temporary Storage (otpStore): An in-memory object otpStore is used for demonstration only. This is NOT production-ready. It links pinId to phoneNumber.
  4. Secure Storage Association: A commented-out section illustrates conceptually how express-session would be used in a real application. The key steps are:
    • Configure express-session with a secret and persistent store.
    • After sendOtp succeeds, store pinId, associated phoneNumber, and timestamp in req.session.
    • In /verify-otp, retrieve these details from req.session, perform necessary checks (e.g., does req.body.phoneNumber match req.session.otpPhoneNumber?), and then call verifyOtp.
    • Clean up session variables after verification (success or failure) or expiry.
  5. /api/send-otp Route (POST):
    • Applies otpSendLimiter.
    • Extracts and validates phoneNumber (emphasizing need for better validation).
    • Calls infobipService.sendOtp.
    • Stores the returned pinId server-side (using otpStore demo or req.session).
    • Returns only a success message, not the pinId.
    • Includes error handling.
  6. /api/verify-otp Route (POST):
    • Applies otpVerifyLimiter.
    • Extracts and validates phoneNumber and otpCode.
    • Retrieves the corresponding pinId from server-side storage (otpStore demo or req.session).
    • Checks if an OTP request exists and hasn't expired locally (optional timestamp check).
    • Calls infobipService.verifyOtp with the retrieved pinId and the user-provided otpCode.
    • If isVerified is true, cleans up storage and returns success. This is the point to grant access/complete the action.
    • If isVerified is false (wrong code, expired on Infobip), returns a 400 error.
    • Includes error handling for unexpected errors during verification.
  7. Server Start: Loads .env, starts the Express server, logs essential information, including a warning if Infobip variables aren't set.
  8. App Export: module.exports = app; is added to allow importing the app instance for automated testing (see Section 13).

4. Configuring Infobip API Credentials for SMS OTP

Get your credentials from Infobip and set up the necessary 2FA Application and Message Template.

4.1 Obtain Infobip API Key and Base URL

  1. Log in to your Infobip Portal.

  2. Your Base URL is usually displayed prominently on the homepage after login, often within an ""API Key Management"" or ""Developer Tools"" section, or mentioned in the API documentation landing page specific to your account. It will look something like xxxxxx.api.infobip.com. Find the correct URL for your account region.

  3. Navigate to the API Key management section (typically found under your account settings, developer tools, or a dedicated ""API"" menu item).

  4. Create a new API key. Give it a descriptive name (e.g., ""Node OTP App Key"").

  5. Securely copy the generated API Key immediately. You will not be able to view it again after closing the creation dialog. Store it safely.

  6. Update your .env file (created in Section 1.6) with these values:

    dotenv
    # .env (Update these lines)
    INFOBIP_BASE_URL=YOUR_COPIED_BASE_URL # e.g., abcde.api.infobip.com
    INFOBIP_API_KEY=YOUR_COPIED_API_KEY
    # ... other variables remain ...

4.2 Create Infobip 2FA Application and Message Template

You need an Application and a Message Template within Infobip to define the behavior (PIN length, expiry, attempts) and content of your OTP messages. You can usually do this via the Infobip API or potentially through their web portal (the availability and location of UI configuration for 2FA might change, check their documentation or portal interface). We outline the API approach here.

  • Using Infobip Portal (If Available): Explore the portal for sections like ""Apps"", ""Channels"", ""Verify"", or ""2FA"". Look for options to create a new ""Application"" (specifically for 2FA/Verify) and associated ""Message Templates"". Configure settings like PIN type, PIN length, validity time (pinTimeToLive), allowed attempts (pinAttempts), and the message text (critically, include the {{pin}} placeholder where the code should appear). If you create them via the UI, carefully note down the generated Application ID and Message ID.

  • Using API (Recommended for Automation/Consistency): Use curl, Postman, or an HTTP client in your preferred language with the Base URL and API Key obtained in Section 4.1. Note: Always refer to the latest Infobip 2FA API Documentation for the most current endpoints and request/response structures.

    a) Create 2FA Application: Send a POST request to https://<YOUR_BASE_URL>/2fa/2/applications. Replace <YOUR_BASE_URL> with the value you added to your .env file.

    Request (curl example - replace placeholders!):

    bash
    # Replace YOUR_BASE_URL and YOUR_API_KEY with your actual values
    curl -X POST https://YOUR_BASE_URL/2fa/2/applications \
    --header 'Authorization: App YOUR_API_KEY' \
    --header 'Content-Type: application/json' \
    --header 'Accept: application/json' \
    --data-raw '{
        ""name"": ""My Nodejs App OTP"",
        ""configuration"": {
            ""pinAttempts"": 5,
            ""allowMultiplePinVerifications"": false,
            ""pinTimeToLive"": ""10m"",
            ""verifyPinLimit"": ""3/10s"",
            ""sendPinPerApplicationLimit"": ""5000/1d"",
            ""sendPinPerPhoneNumberLimit"": ""5/1d""
        },
        ""enabled"": true
    }'

    Adjust configuration values (like pinAttempts, pinTimeToLive, rate limits) based on your security requirements.

    Example Successful Response:

    json
    {
        ""applicationId"": ""HJ675435E3A6EA43432G5F37A635KJ8B"", // <-- Copy this Application ID
        ""name"": ""My Nodejs App OTP"",
        ""configuration"": {
            // ... configuration details reflected ...
        },
        ""enabled"": true
    }

    Copy the applicationId from the response.

    b) Create 2FA Message Template: Send a POST request to https://<YOUR_BASE_URL>/2fa/2/applications/<YOUR_APPLICATION_ID>/messages. Replace <YOUR_BASE_URL> and <YOUR_APPLICATION_ID> with your Base URL and the Application ID you just received.

    Request (curl example - replace placeholders!):

    bash
    # Replace YOUR_BASE_URL, YOUR_APPLICATION_ID, and YOUR_API_KEY
    curl -X POST https://YOUR_BASE_URL/2fa/2/applications/YOUR_APPLICATION_ID/messages \
    --header 'Authorization: App YOUR_API_KEY' \
    --header 'Content-Type: application/json' \
    --header 'Accept: application/json' \
    --data-raw '{
        ""messageText"": ""Your MyApp verification code is: {{pin}}. It expires in 10 minutes. Do not share this code."",
        ""pinLength"": 6,
        ""pinType"": ""NUMERIC"",
        ""language"": ""en"",
        ""senderId"": ""InfoSMS""
        # ""senderId"": ""YourBrand"" # Use a registered Alphanumeric Sender ID if available/required
        # ""regional"": { /* Optional regional settings like content templates for India */ }
    }'

    Ensure {{pin}} is included in messageText. Adjust pinLength, pinType as needed. senderId might need configuration/approval depending on the destination country.

    Example Successful Response:

    json
    {
        ""messageId"": ""0130269F44AFD07AEBC2FEFEB30398A0"", // <-- Copy this Message ID
        ""pinType"": ""NUMERIC"",
        ""messageText"": ""Your MyApp verification code is: {{pin}}. It expires in 10 minutes. Do not share this code."",
        ""pinLength"": 6,
        ""language"": ""en"",
        ""senderId"": ""InfoSMS""
        // ... other details ...
    }

    Copy the messageId from the response.

4.3 Update .env File

Add the obtained applicationId and messageId to your .env file:

dotenv
# .env (Update these lines)
INFOBIP_BASE_URL=YOUR_COPIED_BASE_URL
INFOBIP_API_KEY=YOUR_COPIED_API_KEY

# Infobip 2FA Configuration (Obtained in Section 4.2)
INFOBIP_2FA_APP_ID=YOUR_COPIED_APPLICATION_ID # From step 4.2a response
INFOBIP_2FA_MSG_ID=YOUR_COPIED_MESSAGE_ID   # From step 4.2b response

# Application Port
PORT=3000

Explanation of Variables:

  • INFOBIP_BASE_URL: The unique API endpoint URL provided by Infobip for your account.
  • INFOBIP_API_KEY: Your secret key for authenticating API requests. Treat this like a password.
  • INFOBIP_2FA_APP_ID: Identifies the specific 2FA application configuration (rate limits, attempts, expiry) to use.
  • INFOBIP_2FA_MSG_ID: Identifies the specific message template (text, PIN length, sender ID) to use when sending the OTP SMS.

Now your application is configured to communicate with the correct Infobip resources using the settings you defined.

5. OTP Error Handling and Security Best Practices

Error Handling Strategy:

  • Specific Infobip Errors: The infobipService.js functions now attempt to catch specific API errors from Infobip (inspecting error.response.data). Expected failures like WRONG_PIN or PIN_EXPIRED during verification result in verifyOtp returning false, which the route handler translates into a user-friendly 400 response. Other API errors (config, auth, network) are caught and logged server-side, resulting in a 500 response with a generic message.
  • General Application Errors: try...catch blocks are used in route handlers and service functions to catch unexpected JavaScript errors or issues within the application logic. A basic global error handler is added to server.js as a fallback.
  • User Feedback: API responses provide clear status codes (200, 400, 429, 500) and JSON bodies with message or error fields suitable for client-side display (e.g., ""Invalid or expired OTP code."", ""Too many requests...""). Sensitive internal details are not exposed to the client.

(Code in infobipService.js and server.js includes improved error handling.)

Logging:

Use a dedicated logging library in production (like winston or pino) for structured, leveled logging (info, warn, error) and easy integration with log management systems. For this guide, we use console.log, console.warn, and console.error.

  • Log Key Events: OTP request received, OTP sent success (log pinId server-side only), verification attempt, verification result (success/failure).
  • Log Errors: Log detailed error information on the server, including error messages, stack traces (where available), and potentially relevant request details (excluding sensitive data like full phone numbers or OTP codes unless absolutely necessary and secured).

Frequently Asked Questions

How to implement SMS OTP 2FA in Node.js?

Implement SMS OTP 2FA using Express.js, the Infobip 2FA API, and environment variables for API keys. This setup allows you to send and verify one-time passwords, enhancing security for actions like login or registration by requiring a second verification step beyond a password.

What is the Infobip 2FA API used for?

The Infobip 2FA API is used for sending and verifying one-time passwords (OTPs) via various channels like SMS, voice calls, or email. This guide focuses on SMS OTPs, adding an extra layer of security to user accounts.

Why use express-rate-limit with Infobip OTP?

The `express-rate-limit` middleware helps protect your Node.js application from abuse by limiting the number of OTP requests and verification attempts from a single IP address within a timeframe, enhancing security.

When should I use environment variables for API keys?

Always use environment variables for sensitive information like API keys. The dotenv module in Node.js helps manage environment variables, ensuring your credentials are not exposed in your source code.

How to send an OTP with Infobip API?

Send a POST request to Infobip's /2fa/2/pin endpoint using a library like axios. Provide your application ID, message ID, and user's phone number. The response contains a pinId, which should be stored securely on the server.

How to verify an OTP from Infobip in Node?

Use the pinId received from the send OTP request, store it securely on the server, and the user-provided OTP to make a POST request to Infobip's /2fa/2/pin/{pinId}/verify endpoint. This process confirms if the OTP is valid.

What is the purpose of pinId in Infobip 2FA?

The pinId is a unique identifier for each OTP sent by Infobip's 2FA API. It's crucial for server-side tracking and verifying the correct OTP against the user's attempt, maintaining security and preventing unauthorized access.

Why does Infobip OTP need rate limiting?

Rate limiting protects against brute-force attacks and prevents abuse. It limits how many OTP requests can be made within a certain timeframe, enhancing security.

When should I clean up stored pinId after verification?

Clean up the stored `pinId` and other OTP-related data immediately after successful verification or after a defined number of failed attempts, or upon expiry. This is important for security best practices.

Can I store pinId in an in-memory object for production?

No, storing pinId in an in-memory object like otpStore is NOT suitable for production. Use a database (like Redis, PostgreSQL, MongoDB) or proper session management (like express-session) with a persistent store instead.

What Node.js libraries are required for SMS OTP?

Essential libraries include express for the web framework, dotenv for managing environment variables, axios for making API requests, and express-rate-limit for security. Optionally, a database connector or session management library is needed for production.

How to handle Infobip OTP errors in Node?

Handle errors by checking the error response from the Infobip API. For expected errors like WRONG_PIN, return false. For other errors, log details and return generic error messages to the client, protecting sensitive information.

What is the project structure for Node.js SMS OTP?

The project includes server.js for the main application, infobipService.js for API interaction logic, .env for configuration, .gitignore for version control, node_modules, package.json, and package-lock.json. This structure organizes the core components of the application.