code examples

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

Node.js Express SMS OTP Verification: Complete Infobip 2FA Implementation Guide

A guide on setting up two-factor authentication (2FA) using SMS OTP in a Node.js Express application via the Infobip API.

How to Implement SMS OTP Verification in Node.js with Express and Infobip

Two-factor authentication (2FA) adds a crucial layer of security to user accounts by requiring a second verification step beyond just a password. One common and user-friendly method for 2FA uses One-Time Passwords (OTP) sent via SMS.

This guide provides a complete walkthrough for implementing SMS-based OTP verification in a Node.js application using the Express framework and the Infobip 2FA API. You'll build a simple API that sends an OTP to a user's phone number and verifies the code they enter.

Project Goals:

  • Create a secure and reliable SMS OTP verification flow
  • Integrate with the Infobip 2FA API for sending and verifying OTPs
  • Build corresponding API endpoints in a Node.js Express application
  • Handle configuration, security, and error scenarios appropriately

Technologies Used:

  • Node.js: JavaScript runtime environment
  • Express.js: Minimalist web framework for Node.js
  • Infobip 2FA API: Service for generating, sending, and verifying OTPs via SMS, Voice, or Email. You'll focus on SMS
  • Axios: Promise-based HTTP client for making requests to the Infobip API
  • dotenv: Module for loading environment variables from a .env file

System Architecture:

The basic flow involves your Node.js application interacting with the Infobip API. Your Node.js app receives requests from the client, forwards OTP generation requests to Infobip, receives the pinId back, responds to the client that the OTP is sent, and later receives verification requests from the client to validate against Infobip. Infobip handles sending the SMS to the user after your Node.js app requests it.

mermaid
sequenceDiagram
    participant User
    participant ClientApp as Client App (e.g., Web/Mobile)
    participant NodeApp as Node.js Express API
    participant Infobip

    User->>ClientApp: Initiates action requiring OTP (e.g., Login, Profile Update)
    ClientApp->>NodeApp: POST /send-otp (with phone number)
    NodeApp->>Infobip: POST /2fa/2/pin (Generate & Send OTP Request)
    Infobip-->>NodeApp: Response (includes pinId)
    NodeApp-->>ClientApp: Success response (OTP sent confirmation)
    Infobip->>User: Sends SMS with OTP code (happens after NodeApp request)
    User->>ClientApp: Enters OTP code
    ClientApp->>NodeApp: POST /verify-otp (with phone number, OTP code)
    NodeApp->>Infobip: POST /2fa/2/pin/{pinId}/verify (Verify OTP Request using stored pinId)
    Infobip-->>NodeApp: Response (verified: true/false)
    NodeApp-->>ClientApp: Verification result (Success/Failure)
    ClientApp->>User: Shows appropriate message (Login success, Error, etc.)

Prerequisites:

  • Node.js and npm (or yarn): Install these on your system (Download Node.js)
  • Infobip Account: Register for an Infobip account. Sign up for a free trial (Infobip Signup)
  • Infobip API Key and Base URL: Obtain these from your Infobip account dashboard after signup
  • Basic understanding of Node.js, Express, and REST APIs

Final Outcome:

By the end of this guide, you'll have a functional Node.js Express API with two endpoints: /send-otp and /verify-otp. This API leverages Infobip to handle the complexities of OTP generation, delivery via SMS, and verification, ready to integrate into a larger application's authentication flow.


1. Setting Up Your Node.js OTP Project

Start by creating your Node.js project and installing the necessary dependencies.

1. Create Project Directory: Open your terminal and create a new directory for the project.

bash
mkdir infobip-otp-guide
cd infobip-otp-guide

2. Initialize Node.js Project: Initialize the project using npm (or yarn). This creates a package.json file.

bash
npm init -y

3. Install Dependencies: Install Express for the web server, dotenv for environment variable management, and axios for making HTTP requests to the Infobip API.

bash
npm install express dotenv axios

4. Project Structure: Create the basic files and folders.

bash
touch index.js .env .gitignore

Your initial structure should look like this:

infobip-otp-guide/ ├── node_modules/ ├── .env ├── .gitignore ├── index.js └── package.json

5. Configure .gitignore: Add node_modules and .env to your .gitignore file to prevent committing sensitive information and dependencies.

text
node_modules
.env

6. Basic Express Server Setup: Add the following initial code to index.js to set up a basic Express server.

javascript
// Load environment variables from .env file
require('dotenv').config();

const express = require('express');
const app = express();

// Middleware to parse JSON request bodies
app.use(express.json());

const PORT = process.env.PORT || 3000;

app.get('/', (req, res) => {
  res.send('Infobip OTP Service Running!');
});

// --- OTP Routes will go here ---

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

You can run this basic server to ensure setup is correct:

bash
node index.js

You should see Server running on port 3000 in your console. You can stop the server with Ctrl+C.


2. Configuring Infobip 2FA API Credentials

Before interacting with the Infobip API, configure it within Infobip and store the necessary credentials securely in your application.

1. Obtain Infobip API Key and Base URL:

  • Log in to your Infobip account
  • Navigate to the API Keys section (often found under account settings or developer tools)
  • Create a new API key if you don't have one. Copy this key immediately – you might not see it again
  • Note your Base URL. This is the domain you'll use for API requests (e.g., your-account.api.infobip.com). The specific Base URL usually appears near your API keys or in the general API documentation entry point for your account

2. Store Credentials in .env: Open the .env file and add your Infobip credentials.

dotenv
# Infobip Credentials
INFOBIP_API_KEY=YOUR_COPIED_API_KEY
INFOBIP_BASE_URL=YOUR_BASE_URL # e.g., xyz123.api.infobip.com

# Infobip 2FA Configuration (You will get these next)
INFOBIP_2FA_APP_ID=
INFOBIP_2FA_MSG_ID=

# Server Port (Optional)
PORT=3000

Replace YOUR_COPIED_API_KEY and YOUR_BASE_URL with your actual values. Do not commit the .env file to version control.

3. Create Infobip 2FA Application: An Infobip Application defines the behavior and rules for your 2FA flow (like PIN attempts, validity time). Create one using the Infobip API.

  • Open your terminal (or use a tool like Postman)
  • Replace placeholders: Substitute YOUR_BASE_URL and YOUR_API_KEY with your actual credentials in the command below
  • Important: The Authorization header format requires the literal word App, followed by a space, then your API key: -H 'Authorization: App YOUR_API_KEY'
  • Execute the curl command:
bash
curl -X POST \
  https://YOUR_BASE_URL/2fa/2/applications \
  -H 'Authorization: App YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
        "name": "My Nodejs App OTP",
        "configuration": {
          "pinAttempts": 10,
          "allowMultiplePinVerifications": true,
          "pinTimeToLive": "10m",
          "verifyPinLimit": "1/3s",
          "sendPinPerApplicationLimit": "10000/1d",
          "sendPinPerPhoneNumberLimit": "3/1d"
        },
        "enabled": true
      }'
  • Explanation of Configuration:
    • name: A descriptive name for this 2FA configuration
    • pinAttempts: Maximum number of verification attempts allowed for a single PIN
    • allowMultiplePinVerifications: Whether you can verify the same PIN multiple times (useful for testing, potentially disable in production)
    • pinTimeToLive: How long the generated PIN remains valid (e.g., "10m" for 10 minutes)
    • verifyPinLimit: Rate limit for verification attempts per PIN ID
    • sendPinPerApplicationLimit: Rate limit for sending PINs across the entire application
    • sendPinPerPhoneNumberLimit: Rate limit for sending PINs to a single phone number
    • enabled: Whether this configuration is active
  • Capture the applicationId: The API response contains an applicationId. Copy this value.
json
// Example Response
{
  "applicationId": "ABC123DEF456GHI789JKL012MNO345PQR", // <-- Copy this value
  "name": "My Nodejs App OTP",
  "configuration": {
    // ... configuration details ...
  },
  "enabled": true
}
  • Update .env: Add the copied applicationId to your .env file:
dotenv
# ... other variables ...
INFOBIP_2FA_APP_ID=ABC123DEF456GHI789JKL012MNO345PQR
# ... other variables ...

4. Create Infobip Message Template: This template defines the content of the SMS message sent to the user, including the placeholder for the OTP code.

  • Replace placeholders: Substitute YOUR_BASE_URL, YOUR_API_KEY, and YOUR_2FA_APP_ID in the command below
  • Choose Sender ID: The senderId is what appears as the sender on the user's phone. For trial accounts, this might be restricted. For paid accounts, you can often register a custom alphanumeric sender ID or use a purchased phone number. Check Infobip documentation for specifics in your region. Replace "InfoSMS" with your desired/allowed sender ID
  • Important: Ensure the Authorization header uses the App YOUR_API_KEY format
  • Execute the curl command:
bash
curl -X POST \
  https://YOUR_BASE_URL/2fa/2/applications/YOUR_2FA_APP_ID/messages \
  -H 'Authorization: App YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
        "messageText": "Your verification code is {{pin}}. It expires in 10 minutes.",
        "pinLength": 6,
        "pinType": "NUMERIC",
        "senderId": "InfoSMS",
        "language": "en"
      }'
  • Explanation:
    • messageText: The SMS body. {{pin}} is the mandatory placeholder for the OTP
    • pinLength: The number of digits for the OTP (e.g., 6)
    • pinType: The type of PIN (NUMERIC, ALPHA, HEX, ALPHANUMERIC)
    • senderId: The sender ID displayed to the user
    • language: Helps with potential future localization or specific character encoding needs
  • Capture the messageId: The API response contains a messageId. Copy this value.
json
// Example Response
{
  "messageId": "XYZ987WVU654TSR321QPO098NML765KJI", // <-- Copy this value
  "messageText": "Your verification code is {{pin}}. It expires in 10 minutes.",
  // ... other template details ...
}
  • Update .env: Add the copied messageId to your .env file:
dotenv
# ... other variables ...
INFOBIP_2FA_MSG_ID=XYZ987WVU654TSR321QPO098NML765KJI
# ... other variables ...

Now your application has the necessary credentials and configuration IDs stored securely.


3. Implementing SMS OTP Send and Verify Functions

Write the functions that interact with the Infobip API. You'll create helper functions for sending and verifying OTPs.

1. Setup Axios Instance: Create a pre-configured Axios instance for interacting with the Infobip API. Add this near the top of index.js:

javascript
// ... require statements ...
const axios = require('axios'); // Ensure axios is required

const app = express();
app.use(express.json());

// Configure Axios instance for Infobip API
const infobipAxios = axios.create({
  baseURL: `https://${process.env.INFOBIP_BASE_URL}`, // Use HTTPS
  headers: {
    'Authorization': `App ${process.env.INFOBIP_API_KEY}`, // Note the 'App ' prefix
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  }
});

// --- In-memory storage for PIN IDs (Replace with DB/Redis in production) ---

WARNING: PRODUCTION UNSUITABLE – DO NOT USE IN PRODUCTION

The activePinIds object stores PIN IDs in server memory. This approach WILL NOT WORK correctly in a production environment:

  1. Data Loss: Server restarts erase all active PIN IDs
  2. Scalability Issues: You cannot scale horizontally (multiple instances) because each instance has its own separate memory store

REPLACE this with a persistent, shared storage solution like Redis (recommended for TTL support) or a database before deploying. See Section 6 for details.

javascript
const activePinIds = {}; // Stores mapping: phoneNumber -> pinId

// ... rest of the server setup ...

2. Send OTP Function: This function takes a phone number, calls the Infobip API to send the PIN, and stores the returned pinId associated with the phone number (temporarily in memory for this example).

javascript
// ... infobipAxios setup ...
// ... activePinIds setup ...

/**
 * Sends an OTP via Infobip to the specified phone number.
 * @param {string} phoneNumber - The recipient's phone number in E.164 format (e.g., +447... or +1415...)
 * @returns {Promise<object>} - Object containing pinId and recipient number
 * @throws {Error} - If the API call fails or configuration is missing
 */
async function sendOtp(phoneNumber) {
  const appId = process.env.INFOBIP_2FA_APP_ID;
  const msgId = process.env.INFOBIP_2FA_MSG_ID;

  if (!appId || !msgId) {
    throw new Error("Infobip App ID or Message ID is missing in configuration.");
  }
  if (!phoneNumber) {
      throw new Error("Phone number is required.");
  }

  console.log(`Sending OTP to: ${phoneNumber}`);

  try {
    const response = await infobipAxios.post('/2fa/2/pin', {
      applicationId: appId,
      messageId: msgId,
      to: phoneNumber,
      // 'from' can be specified here if needed, otherwise uses template's default
    });

    const pinId = response.data.pinId;
    console.log(`OTP sent successfully. Pin ID: ${pinId}`);

    // Store the pinId associated with the phone number for verification
    // WARNING: Using IN-MEMORY storage – REPLACE for production (see Section 6)
    activePinIds[phoneNumber] = pinId;

     // Optionally, schedule removal of pinId after expiry (e.g., 10 mins)
     // This helps clean up memory but doesn't solve the core persistence/scaling issue
     setTimeout(() => {
        if (activePinIds[phoneNumber] === pinId) { // Only delete if it hasn't been replaced by a newer OTP request
            delete activePinIds[phoneNumber];
            console.log(`Expired pinId ${pinId} removed for ${phoneNumber}`);
        }
    }, 10 * 60 * 1000 + 5000); // 10 minutes (matching pinTimeToLive) + 5 seconds buffer


    return { pinId: pinId, to: response.data.to };

  } catch (error) {
    console.error("Error sending OTP:", error.response ? error.response.data : error.message);
    // Extract more specific error info if available
    const errorData = error.response?.data?.requestError?.serviceException;
    const errorMessage = errorData ? `${errorData.messageId}: ${errorData.text}` : 'Failed to send OTP.';
    throw new Error(errorMessage);
  }
}

// ... rest of the server setup ...
  • Key points:
    • Uses the configured applicationId and messageId
    • Takes the phone number (to) as input. Ensure it's in the correct format (usually E.164, e.g., +14155552671 or +442071838750). Infobip is generally flexible but E.164 is standard
    • Makes a POST request to /2fa/2/pin
    • Extracts the pinId from the successful response. This pinId is essential for the verification step
    • Stores the pinId in the simple activePinIds object, mapping the phone number to its latest pinId. Replace this for production
    • Includes basic error handling and logging

3. Verify OTP Function: This function takes the phone number, the user-submitted PIN, retrieves the corresponding pinId from the temporary store, and calls the Infobip API to verify the PIN.

javascript
// ... sendOtp function ...

/**
 * Verifies an OTP using the pinId associated with the phone number.
 * @param {string} phoneNumber - The user's phone number used to retrieve the pinId
 * @param {string} pin - The OTP code entered by the user
 * @returns {Promise<boolean>} - True if verification is successful, false otherwise
 * @throws {Error} - If the API call fails or configuration is missing
 */
async function verifyOtp(phoneNumber, pin) {
  // Retrieve pinId from temporary storage – REPLACE for production
  const pinId = activePinIds[phoneNumber];

  if (!pinId) {
    console.warn(`No active PIN ID found for phone number: ${phoneNumber}. It may have expired or was never sent.`);
    return false; // Treat as verification failure
  }
   if (!pin) {
      throw new Error("PIN code is required for verification.");
  }

  console.log(`Verifying OTP for Pin ID: ${pinId}`);

  try {
    const response = await infobipAxios.post(`/2fa/2/pin/${pinId}/verify`, {
      pin: pin,
    });

    const isVerified = response.data.verified;
    console.log(`Verification result for ${pinId}: ${isVerified}`);

    if (isVerified) {
      // Verification successful, remove the pinId from active storage
      delete activePinIds[phoneNumber];
    } else {
       // Optional: Handle failed attempts logic here if needed (e.g., increment counter)
       // If pinAttempts limit is reached on Infobip side, subsequent verify calls will fail anyway
       console.log(`Incorrect PIN entered for ${pinId}`);
    }

    return isVerified;

  } catch (error) {
    console.error(`Error verifying OTP for Pin ID ${pinId}:`, error.response ? error.response.data : error.message);
     // Check for specific errors from Infobip if possible
    const errorData = error.response?.data?.requestError?.serviceException;
     if (errorData?.messageId === 'TOO_MANY_ATTEMPTS') {
         console.warn(`Verification blocked for ${pinId} due to too many attempts.`);
         delete activePinIds[phoneNumber]; // Invalidate the pinId locally
         return false; // Treat as verification failure
     }
     if (error.response?.status === 404) { // Pin ID not found / expired on Infobip's side
        console.warn(`Pin ID ${pinId} not found or expired.`);
        delete activePinIds[phoneNumber]; // Clean up local state
        return false; // Treat as verification failure
     }

    // Rethrow for other unexpected errors
    const errorMessage = errorData ? `${errorData.messageId}: ${errorData.text}` : 'Failed to verify OTP.';
    throw new Error(errorMessage);
  }
}

// ... rest of the server setup ...
  • Key points:
    • Retrieves the pinId from the activePinIds store using the phone number. Handles the case where no pinId is found (e.g., expired or never sent)
    • Makes a POST request to /2fa/2/pin/{pinId}/verify, including the pinId in the URL and the user's submitted pin in the body
    • Checks the verified property in the response (true/false)
    • Removes the pinId from storage upon successful verification to prevent reuse. This relies on the temporary store
    • Includes error handling, specifically logging warnings for incorrect PINs and handling common Infobip errors like TOO_MANY_ATTEMPTS or 404 Not Found (which usually indicates an expired/invalid pinId)

4. Creating Express API Endpoints for OTP

Now, expose the sendOtp and verifyOtp functions through Express API endpoints.

1. Define API Routes: Add the following route handlers in index.js before the app.listen call.

javascript
// ... verifyOtp function ...

// === API Endpoints ===

app.post('/send-otp', async (req, res) => {
  const { phoneNumber } = req.body;

  if (!phoneNumber) {
    return res.status(400).json({ error: 'Phone number is required.' });
  }

  // Basic validation (very simple, consider using a library like libphonenumber-js for robust validation)
  if (!/^\+?[1-9]\d{1,14}$/.test(phoneNumber)) {
     return res.status(400).json({ error: 'Invalid phone number format. Use E.164 format (e.g., +14155552671).' });
  }


  try {
    const result = await sendOtp(phoneNumber);
    // SECURITY CRITICAL: Do NOT send the pinId back to the client.
    // The pinId is a server-side identifier used to link the verification attempt
    // back to the original send request. Exposing it could potentially lead to
    // information leakage or misuse if not handled carefully on the client-side
    // (which is unnecessary complexity). The server manages the pinId internally.
    // The client only needs confirmation that the OTP was requested successfully.
    res.status(200).json({ message: 'OTP sent successfully.' /* , pinId: result.pinId */ }); // Ensure pinId is NOT returned
  } catch (error) {
    // Log the detailed error on the server, but send a generic error to the client
    console.error("Send OTP endpoint error:", error);
    res.status(500).json({ error: error.message || 'Failed to send OTP.' });
  }
});

app.post('/verify-otp', async (req, res) => {
  const { phoneNumber, pin } = req.body;

  if (!phoneNumber || !pin) {
    return res.status(400).json({ error: 'Phone number and PIN code are required.' });
  }
   // Basic validation
   if (!/^\d{6}$/.test(pin)) { // Assuming 6-digit numeric PIN based on template
      return res.status(400).json({ error: 'Invalid PIN format. Expecting 6 digits.' });
   }
    if (!/^\+?[1-9]\d{1,14}$/.test(phoneNumber)) {
     return res.status(400).json({ error: 'Invalid phone number format. Use E.164 format.' });
  }


  try {
    // This function uses the temporary 'activePinIds' store. Needs replacement for production
    const isVerified = await verifyOtp(phoneNumber, pin);

    if (isVerified) {
      res.status(200).json({ message: 'OTP verified successfully.' });
    } else {
      // Use 401 Unauthorized or 400 Bad Request for failed verification attempts
      // (due to incorrect PIN, expired PIN, or no active attempt found)
      res.status(401).json({ error: 'Invalid or expired OTP.' });
    }
  } catch (error) {
     // Log the detailed server error but send a generic message to the client
     console.error("Verification endpoint error:", error);
    res.status(500).json({ error: 'Failed to verify OTP.' });
  }
});


// ... app.listen ...
  • Key points:
    • /send-otp (POST): Expects phoneNumber in the JSON body. Calls sendOtp. Returns a success message. Explicitly states the security reason for not returning the pinId to the client. Basic phone number format validation added
    • /verify-otp (POST): Expects phoneNumber and the pin code in the JSON body. Calls verifyOtp (which currently depends on the temporary activePinIds store). Returns success (200) or failure (401) based on the result. Basic PIN and phone number validation added
    • Includes basic input validation for required fields and formats
    • Handles errors from the service functions and returns appropriate HTTP status codes (400 for bad client input, 500 for server errors, 401 for failed verification). Server logs detailed errors, client receives generic messages

2. Testing with curl or Postman:

  • Start the server: node index.js

  • Send OTP: (Replace +1XXXXXXXXXX with a valid phone number, preferably one associated with your Infobip trial account if applicable)

    bash
    curl -X POST http://localhost:3000/send-otp \
      -H "Content-Type: application/json" \
      -d '{ "phoneNumber": "+1XXXXXXXXXX" }'

    Expected Response (200 OK):

    json
    { "message": "OTP sent successfully." }

    You should receive an SMS on the target phone number.

  • Verify OTP: (Replace +1XXXXXXXXXX with the same phone number and 123456 with the actual code you received)

    bash
    curl -X POST http://localhost:3000/verify-otp \
      -H "Content-Type: application/json" \
      -d '{ "phoneNumber": "+1XXXXXXXXXX", "pin": "123456" }'

    Expected Response (Success – 200 OK):

    json
    { "message": "OTP verified successfully." }

    Expected Response (Incorrect PIN – 401 Unauthorized):

    json
    { "error": "Invalid or expired OTP." }

5. Error Handling and Logging Best Practices

You've added basic try...catch blocks and logging. Refine this approach for production.

  • Consistent Error Handling: The current approach returns 400 for client errors, 401 for failed auth, and 500 for server/API errors – a good start. Ensure error messages logged on the server are detailed, but client-facing messages are less specific for security (e.g., "Invalid or expired OTP" instead of "PIN expired")
  • Logging: You're using console.log and console.error. For production, use a dedicated logging library (like Winston or Pino) to:
    • Write logs to files or external services
    • Set different log levels (debug, info, warn, error)
    • Standardize log formats (e.g., JSON) for easier parsing
  • Retry Mechanisms: Network issues can occur when calling Infobip
    • Idempotency: Sending an OTP is not typically idempotent (sending twice generates two different PINs). Verification might be, depending on Infobip's implementation (verifying the same correct PIN twice might succeed both times if allowMultiplePinVerifications is true, but it uses up an attempt)
    • Strategy: Do not automatically retry the /send-otp endpoint from the server-side, as it could lead to multiple unwanted SMS messages and costs. If a send request fails, return an error to the client and let the user retry the action. For /verify-otp, a limited retry (e.g., 1 retry on a 5xx error or timeout from Infobip) might be acceptable, but be cautious. Implement retries with exponential backoff (wait longer between each retry) using libraries like axios-retry if deemed necessary

Example (Conceptual Logging Enhancement):

javascript
// Replace console.log/error with a proper logger instance
// const logger = require('./logger'); // Assuming logger setup elsewhere

// In sendOtp error handler:
// logger.error({ message: "Error sending OTP", phoneNumber: phoneNumber, errorDetails: error.response?.data || error.message, stack: error.stack });
// throw new Error('Failed to send OTP.'); // Generic message for client

// In verifyOtp error handler:
// logger.error({ message: "Error verifying OTP", pinId: pinId, errorDetails: error.response?.data || error.message, stack: error.stack });
// throw new Error('Failed to verify OTP.'); // Generic message for client

6. Production Database Integration for OTP Storage

The current implementation uses an in-memory object (activePinIds) to store the mapping between phone numbers and active pinIds. This is explicitly unsuitable for production for the reasons highlighted in the warning block in Section 3 (data loss on restart, inability to scale).

Production Approach: Replacing In-Memory Storage

You must replace the activePinIds object with a persistent and potentially shared data store before deploying. Common choices include:

  • Redis: An in-memory data store often used for caching and temporary data. Highly recommended for storing OTP pinIds because it has built-in support for Time-To-Live (TTL). Set the TTL to match the pinTimeToLive configured in Infobip (e.g., 10 minutes), and Redis automatically expires the entry
    • Schema: Key: otp:phoneNumber:+1XXXXXXXXXX, Value: pinId:YYYYYYYYYY, TTL: 600 seconds (for 10 min expiry)
    • Benefits: Fast lookups, automatic expiry management, scalable

Using SQL Database for OTP Storage

  • Relational Database (PostgreSQL, MySQL, etc.): Add columns to your users table or create a separate otp_attempts table. This requires manual cleanup of expired entries (e.g., via a scheduled job)
    • Schema (users table enhancement):
      • otp_pin_id (VARCHAR, nullable): Stores the latest active pinId
      • otp_pin_expires_at (TIMESTAMP, nullable): Stores when the pinId expires (requires logic to check expiry)
    • Schema (otp_attempts table):
      • id (PK)
      • user_id (FK to users, optional)
      • phone_number (VARCHAR, indexed)
      • pin_id (VARCHAR, unique)
      • expires_at (TIMESTAMP, indexed)
      • verified_at (TIMESTAMP, nullable)
      • created_at (TIMESTAMP) (requires cleanup job)

Implementation Sketch (using Redis with ioredis):

javascript
// Requires 'ioredis' client: npm install ioredis
// const Redis = require('ioredis');
// const redisClient = new Redis({ /* connection options, e.g., process.env.REDIS_URL */ });

// In sendOtp function (replace activePinIds[phoneNumber] = pinId):
// const pinTTLSeconds = 10 * 60; // Match pinTimeToLive (10 minutes)
// await redisClient.set(`otp:phoneNumber:${phoneNumber}`, pinId, 'EX', pinTTLSeconds);
// console.log(`Stored pinId ${pinId} in Redis for ${phoneNumber} with TTL ${pinTTLSeconds}s`);

// In verifyOtp function (replace const pinId = activePinIds[phoneNumber]):
// const pinId = await redisClient.get(`otp:phoneNumber:${phoneNumber}`);
// if (!pinId) {
//   console.warn(`No active PIN ID found in Redis for phone number: ${phoneNumber}`);
//   return false; // Not found or expired
// }
// ... make verification call to Infobip ...
// if (isVerified) {
//   // Verification successful, remove the pinId from Redis
//   await redisClient.del(`otp:phoneNumber:${phoneNumber}`);
// } else if (errorData?.messageId === 'TOO_MANY_ATTEMPTS' || error.response?.status === 404) {
//   // Also remove from Redis if Infobip says it's invalid/expired/too many attempts
//   await redisClient.del(`otp:phoneNumber:${phoneNumber}`);
// }

Choose the approach that best fits your application's architecture and scale. Redis with TTL is generally the preferred method for this use case due to its performance and built-in expiration handling, simplifying the application logic.


7. SMS Authentication Security Best Practices

Security is paramount for authentication flows.

Rate Limiting for OTP Endpoints

Prevent brute-force attacks on both sending and verification. Infobip has built-in limits (verifyPinLimit, sendPinPerPhoneNumberLimit), but you should also implement limits in your API layer using middleware like express-rate-limit. Apply stricter limits to /verify-otp than /send-otp.

javascript
// npm install express-rate-limit
const rateLimit = require('express-rate-limit');

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

const verifyOtpLimiter = rateLimit({
    windowMs: 5 * 60 * 1000, // 5 minutes
    max: 10, // Limit each IP to 10 verify attempts per windowMs (adjust based on pinAttempts)
    message: 'Too many verification attempts from this IP, please try again after 5 minutes',
    standardHeaders: true,
    legacyHeaders: false,
});

// Apply to specific routes *before* the route handlers
app.use('/send-otp', sendOtpLimiter);
app.use('/verify-otp', verifyOtpLimiter);

Input Validation and Phone Number Formatting

You've added basic checks. For production, use robust libraries:

  • express-validator: For validating request bodies, params, and queries structure and types
  • libphonenumber-js: For parsing, validating, and normalizing phone numbers thoroughly across different regions

Secure Credential Storage

.env is good for local development. In production, use secrets management systems provided by your cloud provider (AWS Secrets Manager, Google Secret Manager, Azure Key Vault) or tools like HashiCorp Vault. Never commit API keys or other secrets to Git.


Frequently Asked Questions (FAQ)

What is SMS OTP verification?

SMS OTP (One-Time Password) verification is a security method where a unique, temporary code is sent to a user's phone via text message. The user must enter this code to verify their identity, providing an additional authentication factor beyond just a password.

How does Infobip 2FA API work with Node.js?

Infobip 2FA API provides endpoints to generate and verify OTPs. Your Node.js application makes HTTP requests to Infobip's API to send SMS messages containing OTPs and later verify the codes users submit. Infobip handles the SMS delivery infrastructure and PIN management.

What is the difference between OTP and 2FA?

OTP (One-Time Password) is a single-use code valid for one login session or transaction. 2FA (Two-Factor Authentication) is a security process that requires two different authentication factors. SMS OTP is one common implementation of 2FA, where the password is the first factor and the OTP code is the second factor.

How long should an OTP code be valid?

OTP codes typically remain valid for 5-10 minutes. This balance provides enough time for legitimate users to receive and enter the code while minimizing the window for potential attacks. In this tutorial, we configure a 10-minute validity period via Infobip's pinTimeToLive setting.

Is SMS OTP secure for authentication?

SMS OTP provides significantly better security than password-only authentication and is widely used. However, SMS has known vulnerabilities (SIM swapping, interception). For high-security applications, consider additional measures like app-based authenticators (TOTP), hardware tokens, or biometric authentication alongside or instead of SMS OTP.

How do I prevent OTP brute force attacks?

Implement multiple layers of protection: rate limiting on your API endpoints (using express-rate-limit), Infobip's built-in attempt limits (pinAttempts), IP-based throttling, and temporary account lockouts after repeated failed attempts. Always log suspicious activity for monitoring.

What phone number format does Infobip require?

Infobip accepts phone numbers in E.164 format, which includes the country code with a + prefix (e.g., +14155552671 for US, +442071838750 for UK). Use the libphonenumber-js library to parse and validate phone numbers from user input before sending to Infobip.

Can I use Infobip for email or voice OTP instead of SMS?

Yes, Infobip 2FA API supports SMS, Voice, and Email channels. This tutorial focuses on SMS, but you can modify the message template and API calls to use voice calls or email delivery. Check Infobip's documentation for channel-specific configuration options.

How much does Infobip SMS OTP cost?

Infobip offers a free trial with credits to test the service. Production pricing varies by destination country, message volume, and whether you use shared or dedicated sender IDs. Check Infobip's pricing page or contact their sales team for specific rates for your use case.

What should I store in production instead of in-memory storage?

Use Redis (recommended) or a relational database (PostgreSQL, MySQL) to store the mapping between phone numbers and PIN IDs. Redis is preferred because it offers built-in TTL (time-to-live) for automatic expiration, matching your OTP validity period. This ensures persistence across server restarts and enables horizontal scaling.


Next Steps and Advanced Features

Now that you have a working SMS OTP verification system, consider these enhancements:

  • User Authentication Flow Integration: Connect OTP verification to your existing user login or registration system
  • Multi-Channel 2FA: Offer users a choice between SMS, email, or authenticator app OTPs
  • Backup Codes: Generate single-use backup codes for account recovery if users lose phone access
  • Security Monitoring: Implement logging and alerting for suspicious OTP patterns (multiple failed attempts, unusual sending volumes)
  • Internationalization: Support phone numbers and SMS delivery across multiple countries and regions
  • Testing Strategy: Create automated tests for OTP flows, including mock Infobip API responses for CI/CD pipelines

This Node.js Express OTP implementation provides a solid foundation for secure user authentication using Infobip's 2FA API.

Frequently Asked Questions

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

Implement 2FA by integrating the Infobip 2FA API into your Node.js Express app. Create API endpoints to send OTPs via SMS to the user's phone number and then verify the code they enter. This enhances security by adding an extra layer of verification beyond passwords.

What is the Infobip 2FA API used for in this guide?

The Infobip 2FA API is the core service used for generating, sending, and verifying one-time passwords (OTPs) through various channels like SMS, voice calls, or email. This guide focuses specifically on using SMS for delivering OTPs.

Why use Infobip for OTP in a Node.js application?

Infobip simplifies OTP implementation by handling the complexities of generation, delivery, and verification. It allows developers to focus on their application logic rather than managing SMS gateways and other infrastructure.

How to send an OTP via SMS with Infobip API?

To send an OTP, make a POST request to the /2fa/2/pin endpoint of the Infobip API. Provide the application ID, message ID, and the user's phone number in the request body.

When should I remove the pinId after OTP verification?

The `pinId` should be removed from your data store immediately after successful verification. This prevents its reuse and enhances security. It's important to use a persistent data store like Redis or a database for managing `pinId`s in production.

Can I use in-memory storage for pinId in production?

No, using in-memory storage like the example's `activePinIds` is unsuitable for production. This is because data is lost on server restarts, and it doesn't work with multiple server instances. Use Redis or a database instead.

How to verify an OTP received via SMS?

Verify the OTP by making a POST request to /2fa/2/pin/{pinId}/verify, providing the `pinId` (received from the send OTP request) and user-entered OTP. The response will indicate whether verification was successful.

What is the correct format for phone numbers?

The recommended format for phone numbers when using Infobip is E.164. This international standard format ensures consistent and reliable delivery. An example is +14155552671.

How can I improve error handling for my OTP API?

Improve error handling by using specific HTTP status codes (400 for bad input, 500 for server errors, 401 for invalid OTPs) and logging detailed errors server-side while providing generic messages to the client.

What are good security practices for implementing OTP?

Use rate limiting to prevent brute-force attacks, validate inputs thoroughly, secure your API credentials, and ensure that the pinId is not exposed to the client in responses.

Why shouldn't I return pinId to the client?

Returning the `pinId` to the client is a security risk. It's a server-side identifier. Exposing it unnecessarily could lead to potential misuse or information leakage if not carefully handled client-side.

What database should I use for storing pinIds in production?

Redis is recommended for storing `pinId`s due to its speed and built-in support for Time-To-Live (TTL), which automatically expires entries. A relational database can also be used but requires manual cleanup of expired entries.

How to setup Infobip to send OTP messages?

Set up Infobip by creating a 2FA application and a message template in your Infobip account. You then store the application ID, message ID, API key, and base URL securely in your application's environment variables.

What technologies are used in this Node.js OTP guide?

This guide utilizes Node.js with Express.js for the web server, the Infobip 2FA API for OTP services, Axios for HTTP requests, and dotenv for managing environment variables.