code examples

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

Plivo SMS OTP & 2FA Implementation in Node.js: Complete Guide with Express and Redis

Step-by-step tutorial for implementing secure SMS-based One-Time Password (OTP) and Two-Factor Authentication (2FA) using Plivo API, Node.js, Express, and Redis with production-ready security practices.

Plivo SMS OTP & 2FA Implementation in Node.js: Complete Guide with Express and Redis

This comprehensive guide walks you through building a production-ready One-Time Password (OTP) verification system for Two-Factor Authentication (2FA) in Node.js using Plivo's SMS API, Express, and Redis.

What is Two-Factor Authentication (2FA) and Why Use SMS OTP?

Two-Factor Authentication (2FA) adds an essential security layer beyond username and password by requiring users to verify their identity through a second channel—something they possess (their phone). SMS-based OTP authentication sends a time-limited, single-use code to users' mobile devices, preventing unauthorized access even if passwords are compromised.

While SMS OTP has known vulnerabilities (SIM swapping, interception), it remains one of the most accessible 2FA methods globally, with near-universal mobile phone coverage and no app installation required. For many applications, SMS OTP provides an optimal balance between security enhancement and user friction.

We will build a simple web application with a backend API that handles:

  1. Generating a secure OTP.
  2. Sending the OTP to a user's phone number via SMS using Plivo.
  3. Storing the OTP temporarily and securely.
  4. Verifying the OTP submitted by the user.

This implementation enhances application security by adding a verification layer based on something the user possesses — their phone.

Technologies Used:

  • Node.js: A JavaScript runtime environment for building server-side applications.
  • Express: A minimal and flexible Node.js web application framework.
  • Plivo: A cloud communications platform providing APIs for SMS and voice. We'll use their Node.js SDK for sending SMS.
  • Redis: An in-memory data structure store, used here for temporarily storing OTPs with automatic expiration.
  • dotenv: A module to load environment variables from a .env file into process.env.

Prerequisites:

  • Node.js and npm (or yarn): Installed on your development machine. (Node.js v14.x or later recommended).
  • Redis: Installed and running locally or accessible remotely.
  • A Plivo Account: Sign up for a free trial if you don't have one.
  • A Plivo Phone Number: Purchase an SMS-enabled number from the Plivo console.
  • Plivo Auth ID and Auth Token: Found on your Plivo console dashboard.
  • Basic understanding of Node.js, Express, REST APIs, and asynchronous JavaScript.
  • A code editor (like VS Code) and a terminal/command prompt.
  • curl or Postman: For testing the API endpoints.

System Architecture:

Here's a high-level overview of how the components interact:

  1. User enters their phone number in the Frontend (Browser).
  2. Frontend sends a POST request to the Backend API (/send-otp) with the phone number.
  3. Backend generates a secure OTP.
  4. Backend stores the OTP in Redis with an expiry time, using the phone number as the key.
  5. Backend uses the Plivo API to send an SMS containing the OTP to the user's phone number.
  6. Plivo delivers the SMS to the User.
  7. Backend sends a success response back to the Frontend.
  8. User enters the received OTP into the Frontend.
  9. Frontend sends a POST request to the Backend API (/verify-otp) with the phone number and the entered OTP.
  10. Backend retrieves the expected OTP from Redis using the phone number key.
  11. If the OTP exists in Redis and matches the user's input:
    • Backend deletes the OTP from Redis.
    • Backend sends a success response (Verified) to the Frontend.
  12. If the OTP does not exist in Redis (expired) or does not match the user's input:
    • Backend sends a failure response (Invalid OTP) to the Frontend.
  13. Frontend displays the verification result (Success or Failure) to the User.

Final Outcome:

By the end of this guide, you will have a functional Node.js/Express application with API endpoints capable of sending OTPs via Plivo SMS and verifying them, backed by Redis for temporary storage. You'll also have a basic frontend to interact with the API.

Related Resources:

Setting Up Your Node.js OTP Project with Plivo

Let's start by creating the project directory, initializing Node.js, installing dependencies, and setting up the basic file structure.

  1. Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.

    bash
    mkdir nodejs-plivo-otp
    cd nodejs-plivo-otp
  2. Initialize Node.js Project: This creates a package.json file to manage project dependencies and scripts.

    bash
    npm init -y
  3. Install Dependencies: We need Express for the web server, the Plivo Node.js SDK, Redis client, and dotenv for environment variables.

    bash
    npm install express plivo redis dotenv
    • express: Web framework.
    • plivo: Plivo Node.js SDK for interacting with the Plivo API.
    • redis: Node.js client for Redis.
    • dotenv: Loads environment variables from a .env file.
  4. Install Development Dependencies (Optional but Recommended): nodemon automatically restarts the server during development when file changes are detected.

    bash
    npm install --save-dev nodemon
  5. Create Project Structure: Create the necessary files and directories.

    bash
    # For Linux/macOS
    touch app.js .env .gitignore config.js public/index.html public/script.js
    
    # For Windows (Command Prompt)
    echo. > app.js
    echo. > .env
    echo. > .gitignore
    echo. > config.js
    mkdir public
    echo. > public\index.html
    echo. > public\script.js
    
    # For Windows (PowerShell)
    New-Item app.js -ItemType File
    New-Item .env -ItemType File
    New-Item .gitignore -ItemType File
    New-Item config.js -ItemType File
    New-Item public -ItemType Directory
    New-Item public/index.html -ItemType File
    New-Item public/script.js -ItemType File
    • app.js: Main application file (Express server setup, routes).
    • .env: Stores sensitive configuration like API keys (will be ignored by Git).
    • .gitignore: Specifies files/directories Git should ignore (like node_modules and .env).
    • config.js: Application configuration (can be used for non-sensitive settings or constants).
    • public/: Directory to serve static frontend files (HTML, CSS, JS).
  6. Configure .gitignore: Prevent sensitive files and generated directories from being committed to version control. Add the following lines to your .gitignore file:

    text
    # Dependencies
    node_modules/
    
    # Environment variables
    .env
    
    # Logs
    logs
    *.log
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*
    
    # Optional editor directories
    .idea
    .vscode
    *.suo
    *.ntvs*
    *.njsproj
    *.sln
    *.sw?
  7. Configure .env: Add placeholders for your Plivo credentials and other configuration. Do not commit this file to Git.

    dotenv
    # .env
    
    # Plivo Credentials (Get from https://console.plivo.com/dashboard/)
    PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID
    PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN
    PLIVO_SENDER_NUMBER=YOUR_PLIVO_SMS_ENABLED_NUMBER # Use E.164 format, e.g., +14155552671
    
    # Redis Configuration
    REDIS_HOST=127.0.0.1
    REDIS_PORT=6379
    # REDIS_PASSWORD=your_redis_password # Uncomment and set if your Redis requires auth
    # REDIS_URL=redis://:password@hostname:port # Alternative connection string
    
    # Application Settings
    PORT=3000
    OTP_LENGTH=6
    OTP_EXPIRY_SECONDS=300 # 5 minutes
    • Why .env? Storing secrets like API keys directly in code is a major security risk. .env files keep them separate and allow different configurations for development, staging, and production without code changes. dotenv loads these into process.env at runtime.
  8. Add npm Scripts: Modify the scripts section in your package.json for easier starting and development:

    json
    {
      "name": "nodejs-plivo-otp",
      "version": "1.0.0",
      "description": "",
      "main": "app.js",
      "scripts": {
        "start": "node app.js",
        "dev": "nodemon app.js",
        "test": "echo \"Error: no test specified\" && exit 1"
      },
      "keywords": [],
      "author": "",
      "license": "ISC",
      "dependencies": {
        "dotenv": "^...",
        "express": "^...",
        "plivo": "^...",
        "redis": "^..."
      },
      "devDependencies": {
        "nodemon": "^..."
      }
    }
    • npm start: Runs the application using Node.
    • npm run dev: Runs the application using nodemon, which watches for file changes and restarts the server automatically.
    • (Note: Replace ^... with actual installed versions in a real package.json)

Building the OTP Backend API with Express and Plivo

Now, let's build the Express server, connect to Redis, and create the API endpoints for sending and verifying OTPs.

  1. Set up Express Server (app.js): Initialize Express, load environment variables, configure middleware, and start the server.

    javascript
    // app.js
    require('dotenv').config(); // Load environment variables from .env file
    const express = require('express');
    const plivo = require('plivo');
    const redis = require('redis');
    
    // Configuration Constants (Consider moving more complex config to config.js if needed)
    const PORT = process.env.PORT || 3000;
    const OTP_LENGTH = parseInt(process.env.OTP_LENGTH || '6', 10);
    const OTP_EXPIRY_SECONDS = parseInt(process.env.OTP_EXPIRY_SECONDS || '300', 10); // 5 minutes
    
    // --- Initialization ---
    const app = express();
    
    // --- Plivo Client ---
    // Ensure Auth ID and Token are provided
    if (!process.env.PLIVO_AUTH_ID || !process.env.PLIVO_AUTH_TOKEN) {
        console.error("Error: Plivo Auth ID or Auth Token not found in environment variables.");
        process.exit(1); // Exit if essential config is missing
    }
    const plivoClient = new plivo.Client(process.env.PLIVO_AUTH_ID, process.env.PLIVO_AUTH_TOKEN);
    const plivoSenderNumber = process.env.PLIVO_SENDER_NUMBER;
    if (!plivoSenderNumber) {
        console.error("Error: Plivo Sender Number not found in environment variables.");
        process.exit(1);
    }
    
    // --- Redis Client ---
    const redisOptions = {
        socket: {
            host: process.env.REDIS_HOST || '127.0.0.1',
            port: parseInt(process.env.REDIS_PORT || '6379', 10),
        }
    };
    if (process.env.REDIS_PASSWORD) {
        redisOptions.password = process.env.REDIS_PASSWORD;
    }
    // Alternative: Use REDIS_URL if provided
    if (process.env.REDIS_URL) {
        redisOptions.url = process.env.REDIS_URL;
    }
    
    const redisClient = redis.createClient(redisOptions);
    
    redisClient.on('error', (err) => console.error('Redis Client Error:', err));
    redisClient.on('connect', () => console.log('Connected to Redis server.'));
    redisClient.connect(); // Connect to Redis v4 client requires explicit connect
    
    // --- Middleware ---
    app.use(express.json()); // Parse JSON request bodies
    app.use(express.urlencoded({ extended: true })); // Parse URL-encoded request bodies
    app.use(express.static('public')); // Serve static files from the 'public' directory
    
    // --- Helper Functions ---
    /**
     * Generates a cryptographically secure random numeric OTP of a specified length.
     * Uses Node.js crypto module for CSPRNG (Cryptographically Secure Pseudo-Random Number Generator).
     * Recommended by OWASP and NIST SP 800-63B for production OTP systems.
     * @param {number} length - The desired length of the OTP.
     * @returns {string} The generated OTP string.
     */
    function generateOTP(length) {
        const crypto = require('crypto');
        const digits = '0123456789';
        let OTP = '';
    
        // Generate cryptographically secure random bytes
        const randomBytes = crypto.randomBytes(length);
    
        for (let i = 0; i < length; i++) {
            // Use modulo to map byte values (0-255) to digit indices (0-9)
            OTP += digits[randomBytes[i] % 10];
        }
    
        return OTP;
    }
    
    /**
     * Basic phone number validation (E.164 format check).
     * Adapt this regex as needed for stricter validation.
     * @param {string} phoneNumber
     * @returns {boolean}
     */
    function isValidPhoneNumber(phoneNumber) {
        const phoneRegex = /^\+[1-9]\d{1,14}$/; // Basic E.164 check
        return phoneRegex.test(phoneNumber);
    }
    
    
    // --- API Routes ---
    
    /**
     * @route POST /send-otp
     * @desc Generates an OTP, stores it in Redis, and sends it via Plivo SMS.
     * @access Public
     * @body { "phoneNumber": "+1XXXXXXXXXX" } - Phone number in E.164 format
     */
    app.post('/send-otp', async (req, res) => {
        const { phoneNumber } = req.body;
    
        // 1. Input Validation
        if (!phoneNumber || !isValidPhoneNumber(phoneNumber)) {
            console.warn(`Invalid phone number format received: ${phoneNumber}`);
            return res.status(400).json({ success: false, message: 'Invalid phone number format. Use E.164 format (e.g., +14155552671).' });
        }
    
        try {
            // 2. Generate OTP
            const otp = generateOTP(OTP_LENGTH);
            const redisKey = `otp:${phoneNumber}`;
    
            // 3. Store OTP in Redis with Expiry
            // 'EX' sets the expiry time in seconds
            await redisClient.set(redisKey, otp, { EX: OTP_EXPIRY_SECONDS });
            console.log(`OTP ${otp} stored for ${phoneNumber}. Expires in ${OTP_EXPIRY_SECONDS}s.`);
    
            // 4. Send OTP via Plivo SMS
            const messageParams = {
                src: plivoSenderNumber,
                dst: phoneNumber,
                text: `Your verification code is: ${otp}. It is valid for ${OTP_EXPIRY_SECONDS / 60} minutes.`,
            };
    
            const response = await plivoClient.messages.create(messageParams.src, messageParams.dst, messageParams.text);
            console.log(`Message sent successfully to ${phoneNumber}. Message UUID: ${response.messageUuid[0]}`); // Plivo API v4 returns messageUuid as an array
    
            return res.status(200).json({ success: true, message: `OTP sent successfully to ${phoneNumber}.` });
    
        } catch (error) {
            console.error(`Error sending OTP to ${phoneNumber}:`, error);
    
            // Handle specific Plivo/Redis errors if needed.
            // Note: Checking error.message.includes(...) is simple but potentially brittle.
            // In production, checking specific error codes or types from the SDKs is more robust.
            if (error.message && error.message.includes('invalid destination number parameter')) {
               return res.status(400).json({ success: false, message: 'Invalid destination phone number provided to Plivo.' });
            }
            if (error.message && error.message.includes('Redis')) { // Example check for Redis errors
                return res.status(500).json({ success: false, message: 'Could not store OTP. Please try again later.' });
            }
    
            return res.status(500).json({ success: false, message: 'An internal server error occurred while sending OTP.' });
        }
    });
    
    /**
     * @route POST /verify-otp
     * @desc Verifies the OTP submitted by the user against the one stored in Redis.
     * @access Public
     * @body { "phoneNumber": "+1XXXXXXXXXX", "otp": "123456" }
     */
    app.post('/verify-otp', async (req, res) => {
        const { phoneNumber, otp } = req.body;
    
        // 1. Input Validation
        if (!phoneNumber || !isValidPhoneNumber(phoneNumber)) {
            console.warn(`Invalid phone number format received for verification: ${phoneNumber}`);
            return res.status(400).json({ success: false, message: 'Invalid phone number format. Use E.164 format.' });
        }
        if (!otp || typeof otp !== 'string' || otp.length !== OTP_LENGTH || !/^\d+$/.test(otp)) {
             console.warn(`Invalid OTP format received for ${phoneNumber}: ${otp}`);
            return res.status(400).json({ success: false, message: `Invalid OTP format. Must be ${OTP_LENGTH} digits.` });
        }
    
        const redisKey = `otp:${phoneNumber}`;
    
        try {
            // 2. Retrieve OTP from Redis
            const storedOtp = await redisClient.get(redisKey);
    
            // 3. Verify OTP
            if (!storedOtp) {
                console.log(`No OTP found in Redis for ${phoneNumber} or it has expired.`);
                return res.status(400).json({ success: false, message: 'OTP not found or expired. Please request a new one.' });
            }
    
            if (storedOtp === otp) {
                console.log(`OTP verified successfully for ${phoneNumber}.`);
                // 4. Delete OTP from Redis upon successful verification
                await redisClient.del(redisKey);
                return res.status(200).json({ success: true, message: 'OTP verified successfully.' });
            } else {
                console.log(`Invalid OTP entered for ${phoneNumber}. Expected: ${storedOtp}, Received: ${otp}`);
                // Optional: Implement attempt counting here to prevent brute force
                return res.status(400).json({ success: false, message: 'Invalid OTP.' });
            }
    
        } catch (error) {
            console.error(`Error verifying OTP for ${phoneNumber}:`, error);
            return res.status(500).json({ success: false, message: 'An internal server error occurred while verifying OTP.' });
        }
    });
    
    // --- Basic Health Check Route ---
    /**
     * @route GET /health
     * @desc Simple health check endpoint.
     * @access Public
     */
    app.get('/health', (req, res) => {
       res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() });
    });
    
    
    // --- Start Server ---
    app.listen(PORT, () => {
        console.log(`Server running on http://localhost:${PORT}`);
        console.log(`Using Plivo Sender Number: ${plivoSenderNumber}`);
        console.log(`OTP Length: ${OTP_LENGTH}, Expiry: ${OTP_EXPIRY_SECONDS} seconds`);
    });
    
    // --- Graceful Shutdown (Optional but good practice) ---
    process.on('SIGINT', async () => {
        console.log('SIGINT signal received: closing HTTP server and Redis connection');
        try {
            await redisClient.quit();
            console.log('Redis client connection closed.');
            process.exit(0);
        } catch (err) {
            console.error('Error during Redis client disconnection:', err);
            process.exit(1);
        }
    });
    
    process.on('SIGTERM', async () => {
        console.log('SIGTERM signal received: closing HTTP server and Redis connection');
        try {
            await redisClient.quit();
            console.log('Redis client connection closed.');
            process.exit(0);
        } catch (err) {
            console.error('Error during Redis client disconnection:', err);
            process.exit(1);
        }
    });
    • Explanation:
      • We load environment variables using dotenv.config().
      • Initialize express, plivoClient, and redisClient. Critical checks ensure Plivo credentials are set.
      • Configure middleware: express.json() for parsing JSON bodies, express.urlencoded() for form data, and express.static() to serve our frontend files from the public directory.
      • generateOTP: Creates a random numeric string using Node.js crypto module for cryptographically secure random numbers. Using Math.random is shown for simplicity; for production, using Node's crypto module is strongly recommended as Math.random is not cryptographically secure.
      • isValidPhoneNumber: A simple regex check for E.164 format. Real-world validation might involve libraries like google-libphonenumber.
      • /send-otp Endpoint:
        • Validates the phone number.
        • Generates an OTP.
        • Stores the OTP in Redis using the phone number as part of the key (otp:<phoneNumber>) and sets an expiry time (EX). Why Redis? It's fast for temporary data and its built-in TTL (Time-To-Live) feature automatically removes expired OTPs, simplifying our logic.
        • Uses plivoClient.messages.create() to send the SMS.
        • Includes basic error handling for Plivo and Redis operations. Note that checking error.message.includes() is simple but potentially brittle; checking specific error codes or types from the SDKs is more robust in production.
      • /verify-otp Endpoint:
        • Validates phone number and OTP format.
        • Retrieves the OTP from Redis using redisClient.get().
        • Compares the stored OTP with the submitted OTP.
        • If they match, it deletes the OTP from Redis using redisClient.del() to prevent reuse and returns success.
        • If they don't match or the OTP isn't found (likely expired), it returns an error.
      • /health Endpoint: A standard endpoint for monitoring services to check if the application is running.
      • The server listens on the configured PORT.
      • Graceful shutdown handlers (SIGINT, SIGTERM) ensure the Redis connection is closed properly when the server stops.
  2. Run the Backend:

    • Make sure your Redis server is running.

    • Replace the placeholder values in your .env file with your actual Plivo credentials and number.

    • Start the server in development mode:

      bash
      npm run dev
    • You should see logs indicating connection to Redis and the server starting.

  3. Test API Endpoints (using curl):

    • Send OTP: Replace +1YOUR_PHONE_NUMBER with your actual phone number in E.164 format.

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

      Expected Response (Success): { "success": true, "message": "OTP sent successfully to +1YOUR_PHONE_NUMBER." } Expected Action: You should receive an SMS on your phone with the OTP. Check the server logs for details.

    • Verify OTP (Correct): Replace +1YOUR_PHONE_NUMBER and THE_OTP_YOU_RECEIVED.

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

      Expected Response (Success): { "success": true, "message": "OTP verified successfully." }

    • Verify OTP (Incorrect):

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

      Expected Response (Failure): { "success": false, "message": "Invalid OTP." }

    • Verify OTP (Expired): Wait for the OTP expiry time (e.g., 5 minutes) and try verifying again with the correct OTP.

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

      Expected Response (Failure): { "success": false, "message": "OTP not found or expired. Please request a new one." }

    • Health Check:

      bash
      curl http://localhost:3000/health

      Expected Response: { "status": "UP", "timestamp": "..." }

Creating the Frontend OTP Verification Interface

To make interaction easier, let's create a basic HTML form that uses JavaScript to communicate with our backend API.

  1. Create public/index.html:

    html
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Plivo OTP Verification</title>
        <style>
            body { font-family: sans-serif; padding: 20px; max-width: 500px; margin: auto; }
            label { display: block; margin-bottom: 5px; }
            input[type="tel"], input[type="text"] { width: 100%; padding: 8px; margin-bottom: 15px; box-sizing: border-box; }
            button { padding: 10px 15px; cursor: pointer; }
            #otpSection { display: none; margin-top: 20px; }
            .message { margin-top: 15px; padding: 10px; border-radius: 4px; }
            .success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
            .error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
        </style>
    </head>
    <body>
        <h1>Phone Number Verification</h1>
    
        <div id="phoneSection">
            <form id="sendOtpForm">
                <label for="phoneNumber">Phone Number (E.164 format, e.g., +14155552671):</label>
                <input type="tel" id="phoneNumber" name="phoneNumber" required>
                <button type="submit">Send OTP</button>
            </form>
        </div>
    
        <div id="otpSection">
            <p>An OTP has been sent to <strong id="displayPhoneNumber"></strong>. Please enter it below:</p>
            <form id="verifyOtpForm">
                <label for="otp">Enter OTP:</label>
                <input type="text" id="otp" name="otp" required pattern="\d{6}" title="Enter the 6-digit OTP">
                <button type="submit">Verify OTP</button>
            </form>
             <button id="resendOtpButton" style="margin-top: 10px; background: none; border: none; color: blue; text-decoration: underline; cursor: pointer;">Resend OTP</button>
        </div>
    
        <div id="resultMessage" class="message" style="display: none;"></div>
    
        <script src="script.js"></script>
    </body>
    </html>
  2. Create public/script.js:

    javascript
    // public/script.js
    const sendOtpForm = document.getElementById('sendOtpForm');
    const verifyOtpForm = document.getElementById('verifyOtpForm');
    const phoneSection = document.getElementById('phoneSection');
    const otpSection = document.getElementById('otpSection');
    const resultMessage = document.getElementById('resultMessage');
    const phoneNumberInput = document.getElementById('phoneNumber');
    const otpInput = document.getElementById('otp');
    const displayPhoneNumber = document.getElementById('displayPhoneNumber');
    const resendOtpButton = document.getElementById('resendOtpButton');
    
    let currentPhoneNumber = ''; // Store the number for verification
    
    // --- Helper Function to Display Messages ---
    function showMessage(message, isError = false) {
        resultMessage.textContent = message;
        resultMessage.className = `message ${isError ? 'error' : 'success'}`;
        resultMessage.style.display = 'block';
    }
    
    function clearMessage() {
         resultMessage.textContent = '';
         resultMessage.style.display = 'none';
    }
    
    // --- Send OTP Logic ---
    async function handleSendOtp(event) {
        event.preventDefault();
        clearMessage();
        const phone = phoneNumberInput.value.trim();
    
        // Basic client-side validation (server validation is primary)
        const phoneRegex = /^\+[1-9]\d{1,14}$/;
        if (!phoneRegex.test(phone)) {
            showMessage('Invalid phone number format. Use E.164 (e.g., +14155552671).', true);
            return;
        }
    
        currentPhoneNumber = phone; // Store for verification step
    
        try {
            const response = await fetch('/send-otp', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ phoneNumber: currentPhoneNumber })
            });
    
            const data = await response.json();
    
            if (response.ok && data.success) {
                showMessage(data.message);
                phoneSection.style.display = 'none'; // Hide phone input
                otpSection.style.display = 'block'; // Show OTP input
                displayPhoneNumber.textContent = currentPhoneNumber;
                otpInput.focus(); // Focus the OTP input field
            } else {
                showMessage(data.message || 'Failed to send OTP.', true);
            }
        } catch (error) {
            console.error('Error sending OTP:', error);
            showMessage('An error occurred. Please try again.', true);
        }
    }
    
    // --- Verify OTP Logic ---
    async function handleVerifyOtp(event) {
        event.preventDefault();
        clearMessage();
        const otp = otpInput.value.trim();
    
        if (!/^\d{6}$/.test(otp)) { // Assuming 6-digit OTP
             showMessage('Invalid OTP format. Must be 6 digits.', true);
             return;
        }
    
        try {
            const response = await fetch('/verify-otp', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ phoneNumber: currentPhoneNumber, otp: otp })
            });
    
            const data = await response.json();
    
            if (response.ok && data.success) {
                showMessage(data.message);
                otpSection.style.display = 'none'; // Hide OTP section on success
                // Optionally, redirect or show a "verified" state
                // alert('Phone Number Verified Successfully!');
            } else {
                showMessage(data.message || 'Failed to verify OTP.', true);
                otpInput.value = ''; // Clear incorrect OTP
                otpInput.focus();
            }
        } catch (error) {
            console.error('Error verifying OTP:', error);
            showMessage('An error occurred during verification. Please try again.', true);
        }
    }
    
    // --- Event Listeners ---
    sendOtpForm.addEventListener('submit', handleSendOtp);
    verifyOtpForm.addEventListener('submit', handleVerifyOtp);
    
    // Resend OTP Button Listener (re-uses handleSendOtp)
    resendOtpButton.addEventListener('click', (event) => {
        // Simulate form submission with the stored phone number
        phoneNumberInput.value = currentPhoneNumber; // Ensure input has value if needed
        handleSendOtp(new Event('submit')); // Trigger send OTP logic again
    });
  3. Test the Frontend:

    • Make sure your backend server (npm run dev) is still running.
    • Open your web browser and navigate to http://localhost:3000.
    • Enter your phone number (E.164 format) and click "Send OTP".
    • You should see the OTP input section appear and receive an SMS.
    • Enter the received OTP and click "Verify OTP".
    • Observe the success or error messages. Try entering incorrect or expired OTPs. Use the "Resend OTP" button.

Plivo SMS API Integration: Configuration and Best Practices

We've already used the Plivo Node.js SDK. Let's review the key parts and configuration:

  1. Initialization:

    javascript
    // app.js
    const plivo = require('plivo');
    
    // Ensure Auth ID and Token are provided
    if (!process.env.PLIVO_AUTH_ID || !process.env.PLIVO_AUTH_TOKEN) {
        console.error("Error: Plivo Auth ID or Auth Token not found...");
        process.exit(1);
    }
    const plivoClient = new plivo.Client(process.env.PLIVO_AUTH_ID, process.env.PLIVO_AUTH_TOKEN);
    
    const plivoSenderNumber = process.env.PLIVO_SENDER_NUMBER;
    if (!plivoSenderNumber) {
       console.error("Error: Plivo Sender Number not found...");
       process.exit(1);
    }
    • We import the plivo library.
    • We instantiate the Client by passing the Auth ID and Auth Token obtained from the Plivo Console Dashboard. These are read securely from environment variables.
    • We also retrieve the PLIVO_SENDER_NUMBER (your Plivo number) from environment variables. This must be an SMS-enabled number purchased in your Plivo account and should be in E.164 format.
  2. Sending SMS:

    javascript
    // app.js (within /send-otp route)
    const messageParams = {
        src: plivoSenderNumber,
        dst: phoneNumber, // The user's phone number (validated E.164)
        text: `Your verification code is: ${otp}. It is valid for ${OTP_EXPIRY_SECONDS / 60} minutes.`,
    };
    
    // The Plivo Node SDK v4 uses positional arguments for messages.create
    const response = await plivoClient.messages.create(
        messageParams.src,
        messageParams.dst,
        messageParams.text
    );
    console.log(`Message sent successfully... Message UUID: ${response.messageUuid[0]}`);
    • plivoClient.messages.create(src, dst, text) is the core function.
    • src (Source): Your Plivo phone number (PLIVO_SENDER_NUMBER).
    • dst (Destination): The recipient's phone number (phoneNumber variable). Must be in E.164 format.
    • text: The content of the SMS message, including the generated otp.
    • The call is asynchronous (await) and returns a response object containing details like the messageUuid upon success, which is useful for tracking and debugging.
  3. Environment Variables:

    • PLIVO_AUTH_ID: Found on your Plivo Console Dashboard. Used to identify your account.
    • PLIVO_AUTH_TOKEN: Found on your Plivo Console Dashboard. Acts as your account's password for API access. Keep this secret.
    • PLIVO_SENDER_NUMBER: An SMS-enabled phone number purchased from your Plivo account, in E.164 format (e.g., +14155552671). This is the number the OTP SMS will be sent from.

Frequently Asked Questions About SMS OTP and 2FA Implementation

How do I generate cryptographically secure OTPs in Node.js?

Use Node.js's built-in crypto.randomBytes() function instead of Math.random(). Generate random bytes and map them to numeric digits using modulo operations. This provides cryptographically secure pseudo-random number generation (CSPRNG) as recommended by OWASP and NIST SP 800-63B for production authentication systems.

What are the security limitations of SMS-based OTP authentication?

According to NIST SP 800-63B, SMS is classified as a "restricted authenticator" due to vulnerabilities including SIM swapping attacks, SMS interception, message preview on locked devices, and phishing susceptibility. NIST recommends against using SMS OTP for applications containing Personally Identifiable Information (PII) or financial transactions. Consider TOTP apps, hardware tokens, or WebAuthn/FIDO2 for higher security requirements.

How do I prevent brute force attacks on OTP verification?

Implement rate limiting on both /send-otp and /verify-otp endpoints using middleware like express-rate-limit. Track failed verification attempts per phone number in Redis with exponential backoff. Lock accounts after 3-5 failed attempts within a time window. Use CAPTCHA after multiple send attempts. Always use cryptographically secure OTP generation to maximize entropy.

OWASP recommends 6-digit OTPs with a 5-10 minute expiration window. Longer OTPs (8 digits) provide more security but reduce usability. Shorter expiry times (2-3 minutes) improve security against interception but may frustrate users with poor network connectivity. Balance security with user experience based on your threat model and user demographics.

How do I secure the OTP delivery message to prevent phishing?

Include your application or brand name in the SMS message. Use consistent message formatting across all OTP communications. Never include links in OTP messages. Consider SMS templates with auto-fill codes (Android Auto-fill, iOS Security Code AutoFill). Add a warning that users should never share the code with anyone, including support staff.

Should I delete OTPs from Redis after failed verification attempts?

No, keep the OTP in Redis until expiration or successful verification. Deleting after failed attempts would allow attackers to request unlimited OTPs. Instead, implement attempt counting: store failed attempt count alongside the OTP in Redis, and lock the phone number after a threshold (e.g., 5 attempts). This prevents enumeration attacks while maintaining security.

How do I implement rate limiting for OTP sending to prevent SMS cost abuse?

Use express-rate-limit middleware to limit OTP requests per IP address (e.g., 3 requests per 15 minutes). Implement additional per-phone-number rate limiting in Redis (e.g., 5 OTP requests per hour per number). Use CAPTCHA after 2-3 requests from the same source. Monitor for anomalous patterns indicating abuse. Set up billing alerts with your Plivo account.

What Redis data structure should I use for storing OTP metadata?

Use Redis Hashes (HSET) to store multiple fields per phone number: OTP value, creation timestamp, attempt count, and last attempt timestamp. Use Redis key expiration (EXPIRE) on the hash key for automatic cleanup. Structure keys as otp:{phoneNumber} for easy querying. This allows atomic operations and reduces race conditions compared to storing separate keys.

How do I handle OTP verification in a distributed Node.js environment?

Ensure Redis is accessible to all Node.js instances (use Redis Cluster or managed Redis services like AWS ElastiCache). Use Redis transactions (MULTI/EXEC) or Lua scripts for atomic read-verify-delete operations to prevent race conditions. Implement distributed rate limiting using Redis counters. Consider using Redis pub/sub for real-time synchronization of blocked phone numbers across instances.

Can I use this OTP system for passwordless authentication?

Yes, but with important caveats. SMS OTP alone provides weaker security than password + OTP (true 2FA). For passwordless authentication, NIST and OWASP recommend combining SMS OTP with device fingerprinting, geolocation checks, or risk-based authentication. Implement account recovery mechanisms and consider progressive enhancement to TOTP or WebAuthn after initial enrollment. Never use SMS OTP alone for high-value accounts or sensitive operations.

Frequently Asked Questions

How to implement 2FA with Plivo in Node.js?

Implement 2FA by integrating Plivo's SMS API into your Node.js/Express app. This involves generating and sending OTPs via SMS, verifying user input, and using Redis for temporary storage. The provided guide offers a step-by-step process to set up this secure verification system. It uses the Plivo Node.js SDK, Express framework and Redis for temporary, secure OTP storage.

What is Plivo used for in Node.js OTP?

Plivo is a cloud communications platform that provides SMS and voice APIs. In this Node.js OTP implementation, Plivo's Node.js SDK is used to send the generated OTP to the user's phone number via SMS. This adds a second layer of security to the authentication process. You'll need a Plivo account, an SMS-enabled number, and your Plivo Auth ID and Token.

Why use Redis for OTP storage in Node?

Redis is used for temporary and secure storage of OTPs in this setup due to its speed and built-in expiry mechanism. OTPs are stored as key-value pairs with the phone number as the key and an expiration time. This prevents OTP reuse and simplifies the backend logic by automatically removing expired codes.

How to send OTP SMS messages with Plivo?

You can send OTPs via SMS using Plivo's Node.js SDK. After initializing the Plivo client with your credentials, use the `plivoClient.messages.create()` method. Provide your Plivo number as the sender, the user's phone number as the recipient, and the OTP as the message content.

How to set up a Node.js project for Plivo OTP?

Initialize a Node.js project using `npm init -y`, then install necessary dependencies like `express`, `plivo`, `redis`, and `dotenv`. Structure your project with files like `app.js`, `.env`, and a `public` folder for frontend assets. Remember to configure your `.gitignore` file.

What Node.js version is recommended for Plivo 2FA?

Node.js v14.x or later is recommended for this Plivo two-factor authentication implementation. Ensure you have npm (or yarn) installed as well to manage the project's dependencies, including the Plivo Node.js SDK and other required packages.

How to generate secure OTPs in Node.js application?

While the example uses `Math.random()` for simplicity, it's not cryptographically secure. For production, the guide recommends using Node's `crypto` module to generate unpredictable OTPs. This ensures a more robust security implementation for your 2FA system.

When to use two-factor authentication with Plivo?

Two-factor authentication (2FA) enhances security by requiring users to verify their identity using something they possess (their phone). This is especially useful for logins, transactions, or any sensitive action within your application.

What are the prerequisites for implementing Plivo OTP?

You'll need Node.js and npm installed, a running Redis server, a Plivo account with an SMS-enabled number, your Plivo Auth ID and Token, and basic knowledge of Node.js, Express, and REST APIs.

How to verify OTPs submitted by users?

The `/verify-otp` endpoint retrieves the expected OTP from Redis using the phone number as a key. It compares this with the user-submitted OTP. If they match, the OTP is deleted from Redis to prevent reuse, and a success response is sent. Otherwise, a failure response is returned.

Where are Plivo Auth ID and Auth Token stored?

Plivo Auth ID and Auth Token, essential for API interaction, should be stored securely within a `.env` file. This file should be excluded from version control (.gitignore) to protect these credentials.

Can I customize OTP length and expiry time?

Yes, you can customize the OTP length and expiry time. These parameters are defined as `OTP_LENGTH` and `OTP_EXPIRY_SECONDS` in the `.env` file and used within the application logic.

What is the role of Express.js in this setup?

Express.js is used as the web application framework, providing a simple way to define routes and handle HTTP requests. It manages API endpoints like `/send-otp` and `/verify-otp`, enabling communication between the frontend and backend.

How to handle invalid phone number formats?

The provided code includes basic input validation for E.164 phone number format using regular expressions. You should adapt the regular expression to match your specific use case, and consider using libraries such as google-libphonenumber for stricter validation.