code examples

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

How to Build SMS OTP Two-Factor Authentication with Twilio, Node.js, and React: Complete Tutorial

A guide to adding SMS OTP verification to a web application using Node.js/Express backend, Vite/React frontend, and the Vonage Verify API.

How to Build SMS OTP Two-Factor Authentication with Twilio, Node.js, and React

Two-factor authentication (2FA) adds a critical layer of security to applications by verifying user identity through two separate factors: something they know (password) and something they have (their phone). SMS-based One-Time Passwords (OTP) provide a widely accessible 2FA method that works with any mobile device.

This comprehensive guide shows you how to implement SMS OTP verification in your web application using Node.js with Express for the backend, Vite (with React or Vue) for the frontend, and the Twilio Verify API for sending and verifying OTPs. Learn to build production-ready two-factor authentication from scratch with complete code examples.

Project Goal: Build a simple web application where users can:

  1. Enter their phone number.
  2. Receive an OTP via SMS.
  3. Enter the OTP to verify their phone number.

Problem Solved: Securely verify user phone numbers to enable 2FA for login, password resets, or transaction confirmations – reducing automated attacks and unauthorized access.

Technologies Used:

  • Node.js & Express: Backend API framework for building REST endpoints.
  • Vite (React): Fast frontend build tool and development server. The examples use React, but you can easily adapt the logic for Vue.
  • Twilio Verify API: Service for OTP generation, SMS delivery, and verification with global carrier reach.
  • dotenv: Environment variable management for secure credential storage.
  • twilio: Official Twilio Node.js SDK for SMS authentication.
  • cors: Cross-Origin Resource Sharing between frontend and backend during development.

System Architecture:

mermaid
graph LR
    A[User's Browser (Vite/React)] -- 1. Enter Phone --> B(Node.js/Express API);
    B -- 2. Request OTP --> C(Twilio Verify API);
    C -- 3. Send SMS --> D[User's Phone];
    D -- 4. User Enters OTP --> A;
    A -- 5. Submit OTP & Verification SID --> B;
    B -- 6. Check OTP --> C;
    C -- 7. Verification Result --> B;
    B -- 8. Send Result --> A;

 classDef default fill:#f9f9f9,stroke:#333,stroke-width:2px;

Note: Ensure your publishing platform supports Mermaid diagrams. If not, replace the code block with a static image and provide descriptive alt text.

Prerequisites:

  • Node.js (v16 or higher) and npm (or yarn) installed.
  • A Twilio account with Verify API access (sign up for free trial credit at twilio.com/console).
  • Basic understanding of Node.js, Express, React (or Vue), and asynchronous JavaScript.
  • A text editor (like VS Code).
  • A tool for testing APIs (like curl or Postman).

Final Outcome: A working application demonstrating the OTP request and verification flow, with separate frontend and backend components.


1. Setting Up Your Node.js and React Project Structure

Create two main directories: backend for your Node.js API and frontend for your Vite/React application.

A. Backend Setup (Node.js/Express)

  1. Create Project Directory & Initialize:

    bash
    mkdir backend
    cd backend
    npm init -y
  2. Install Dependencies:

    bash
    npm install express dotenv twilio cors body-parser
    • express: Web framework for building REST APIs.
    • dotenv: Loads environment variables from a .env file for secure credential management.
    • twilio: Official Twilio Node.js SDK for SMS OTP authentication.
    • cors: Enables Cross-Origin Resource Sharing (required for frontend/backend communication on different ports).
    • body-parser: Parses incoming request bodies. Note: Modern Express (4.16+) includes express.json() and express.urlencoded(), making body-parser optional.
  3. Create Core Files:

    bash
    touch server.js .env .gitignore
  4. Configure .gitignore: Add node_modules and .env to prevent committing them.

    Code
    node_modules
    .env
  5. Set Up Environment Variables (.env):

    Find your Twilio Account SID and Auth Token on the Twilio Console after signing up. You'll also need to create a Verify Service and obtain its SID.

    Code
    # Twilio API Credentials – Obtain from Twilio Console
    TWILIO_ACCOUNT_SID=YOUR_ACCOUNT_SID_HERE
    TWILIO_AUTH_TOKEN=YOUR_AUTH_TOKEN_HERE
    TWILIO_VERIFY_SERVICE_SID=YOUR_VERIFY_SERVICE_SID_HERE
    
    # Server Configuration
    PORT=3001 # Port for the backend API server

    TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN authenticate your application with the Twilio API. TWILIO_VERIFY_SERVICE_SID identifies your Verify service instance. PORT defines where your backend server listens. Storing these in .env keeps secrets out of your code repository.

B. Frontend Setup (Vite/React)

  1. Create Project Directory (from the root folder, outside backend):

    bash
    # Ensure you are in the main project directory, *not* inside ./backend
    npm create vite@latest frontend -- --template react
    cd frontend

    Replace react with vue if you prefer Vue.

  2. Install Dependencies:

    bash
    npm install
  3. Run Development Server:

    bash
    npm run dev

    This starts the Vite development server on http://localhost:5173 (or the next available port).

Project Structure:

text
your-otp-project/
├── backend/
│   ├── node_modules/
│   ├── .env               # Keep secret
│   ├── .gitignore
│   ├── package.json
│   ├── package-lock.json
│   └── server.js          # API logic
└── frontend/
    ├── node_modules/
    ├── public/
    ├── src/               # React code
    ├── .gitignore
    ├── index.html
    ├── package.json
    ├── package-lock.json
    └── vite.config.js

2. Building the Backend API with Twilio Verify

Build two API endpoints in backend/server.js:

  • POST /api/request-otp: Takes a phone number in E.164 format, requests Twilio to send an OTP via SMS, and returns a verification sid.
  • POST /api/verify-otp: Takes the verification sid and the user-entered OTP code, verifies them with Twilio Verify API, and returns the authentication result.

backend/server.js:

javascript
// backend/server.js
const express = require('express');
const twilio = require('twilio');
const cors = require('cors');
const dotenv = require('dotenv');

// Load environment variables
dotenv.config();

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

// --- Middleware ---
// Enable CORS for requests from your frontend development server
// Adjust origin in production
app.use(cors({ origin: 'http://localhost:5173' })); // Or your Vite dev port
app.use(express.json()); // Parse JSON request bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies

// --- Twilio Client Initialization ---
// Validate essential environment variables
if (!process.env.TWILIO_ACCOUNT_SID || !process.env.TWILIO_AUTH_TOKEN || !process.env.TWILIO_VERIFY_SERVICE_SID) {
  console.error('Error: TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, or TWILIO_VERIFY_SERVICE_SID is missing in .env');
  process.exit(1); // Exit if keys are missing
}

const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
const verifyServiceSid = process.env.TWILIO_VERIFY_SERVICE_SID;

// --- In-Memory Store (for demo purposes) ---
// WARNING: Replace with a database (e.g., Redis, PostgreSQL) in production.
// This simple object stores verification SIDs temporarily and will be lost on server restart.
const verificationRequests = {};

// --- API Routes ---

/**
 * @route POST /api/request-otp
 * @desc Request an OTP code to be sent to a phone number via SMS.
 * @access Public
 * @body { phoneNumber: string } - Phone number in E.164 format (e.g., +14155552671)
 */
app.post('/api/request-otp', async (req, res) => {
  const { phoneNumber } = req.body;

  // Basic Input Validation
  if (!phoneNumber || !/^\+[1-9]\d{1,14}$/.test(phoneNumber)) {
    return res.status(400).json({ error: 'Invalid phone number format. Use E.164 format like +14155552671.' });
  }

  console.log(`Requesting OTP for ${phoneNumber}...`);

  try {
    const verification = await client.verify.v2
      .services(verifyServiceSid)
      .verifications.create({ to: phoneNumber, channel: 'sms' });

    if (verification.sid) {
      // Store the verification SID (in production, associate with user ID in database)
      verificationRequests[verification.sid] = { phoneNumber: phoneNumber, status: 'pending' };
      console.log(`OTP request successful. Verification SID: ${verification.sid}`);
      res.status(200).json({ verificationSid: verification.sid, status: verification.status });
    } else {
      // This path indicates an issue even without an explicit error
      console.error('Twilio Error: Unexpected response structure', verification);
      res.status(500).json({ error: 'Failed to start verification. Unexpected response.', details: verification });
    }

  } catch (error) {
    console.error('Twilio Error:', error.message);
    let statusCode = 500;
    let errorMessage = 'Failed to start verification process.';

    if (error.code === 60200) { // Invalid phone number
      statusCode = 400;
      errorMessage = 'Invalid phone number provided.';
    } else if (error.code === 60203) { // Max send attempts reached
      statusCode = 429;
      errorMessage = 'Too many requests for this phone number. Try again later.';
    } else if (error.status === 429) { // Rate limited
      statusCode = 429;
      errorMessage = 'Too many requests. Try again in a few minutes.';
    }

    res.status(statusCode).json({ error: errorMessage, details: error.message });
  }
});

/**
 * @route POST /api/verify-otp
 * @desc Verify the OTP code entered by the user.
 * @access Public
 * @body { verificationSid: string, code: string }
 */
app.post('/api/verify-otp', async (req, res) => {
  const { phoneNumber, code } = req.body;

  // Basic Input Validation
  if (!phoneNumber || !code || typeof code !== 'string' || !/^\d{4,10}$/.test(code)) {
     return res.status(400).json({ error: 'Phone number and a valid code (4–10 digits) are required.' });
  }

  console.log(`Verifying OTP for phone number: ${phoneNumber}...`);

  try {
    const verificationCheck = await client.verify.v2
      .services(verifyServiceSid)
      .verificationChecks.create({ to: phoneNumber, code: code });

    // Check Twilio result status explicitly
    if (verificationCheck.status === 'approved') {
      console.log(`Verification successful for phone number: ${phoneNumber}`);
      // In production: Mark user/phone as verified in your database here
      res.status(200).json({ verified: true, message: 'Phone number verified successfully.' });

    } else {
      // Handle non-approved statuses
      console.warn(`Verification failed for phone number: ${phoneNumber}. Status: ${verificationCheck.status}`);

      let errorMessage = 'Incorrect OTP code.';
      let statusCode = 400;

      if (verificationCheck.status === 'pending') {
        errorMessage = 'Verification still pending. Code may be incorrect.';
      } else if (verificationCheck.status === 'canceled') {
        errorMessage = 'Verification was canceled. Request a new code.';
        statusCode = 410;
      } else if (verificationCheck.status === 'max_attempts_reached') {
        errorMessage = 'Too many incorrect attempts. Request a new code.';
        statusCode = 429;
      }

      res.status(statusCode).json({ verified: false, error: errorMessage, status: verificationCheck.status });
    }

  } catch (error) {
    console.error('Twilio Error during check:', error.message);
    let statusCode = 500;
    let errorMessage = 'Failed to verify code.';

    if (error.code === 60200) { // Invalid parameter
      statusCode = 400;
      errorMessage = 'Invalid verification request.';
    } else if (error.code === 60202) { // Max check attempts reached
      statusCode = 429;
      errorMessage = 'Too many verification attempts. Request a new code.';
    } else if (error.code === 60203) { // Max send attempts reached
      statusCode = 429;
      errorMessage = 'Too many send attempts for this phone number.';
    } else if (error.code === 60205) { // SMS not sent
      statusCode = 500;
      errorMessage = 'Failed to send SMS. Please try again.';
    }

    res.status(statusCode).json({ verified: false, error: errorMessage, details: error.message });
  }
});

// --- Basic Health Check Route ---
app.get('/health', (req, res) => {
  res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() });
});

// --- Start Server ---
app.listen(port, () => {
  console.log(`Backend server listening at http://localhost:${port}`);
});

Explanation:

  1. Imports & Setup: Import necessary libraries, load .env, configure Express middleware (CORS, JSON parsing).
  2. Twilio Initialization: Create the twilio client using credentials from .env. Includes checks for missing keys.
  3. In-Memory Store: A simple JavaScript object verificationRequests temporarily stores the verification sid. This is NOT suitable for production. Use a database (like Redis for caching/session data or PostgreSQL/MongoDB linked to user accounts) to store this information persistently and associate it with the user initiating the request.
  4. /api/request-otp:
    • Receives phoneNumber from the request body.
    • Performs basic E.164 format validation.
    • Calls client.verify.v2.services().verifications.create() with the number and channel ('sms').
    • On success, stores the verification sid locally (in memory) and sends it back to the client.
    • Includes robust try...catch error handling, logging Twilio errors and returning appropriate HTTP status codes (500 for server errors, 400 for bad input, 429 for rate limiting).
  5. /api/verify-otp:
    • Receives phoneNumber and code from the request body.
    • Performs basic validation on inputs.
    • Calls client.verify.v2.services().verificationChecks.create() with the phone number and code.
    • Explicitly checks the verificationCheck.status. Twilio uses 'approved' for success.
    • Handles various Twilio failure statuses with clear messages and appropriate HTTP status codes.
    • Includes robust try...catch for network or unexpected Twilio errors.
  6. Note: The example uses console.log for simplicity. In production, use a structured logger (like Winston or Pino) for better log management and analysis.

3. Creating the React Frontend OTP Verification Form

Create the user interface in the frontend directory.

frontend/src/App.jsx:

jsx
// frontend/src/App.jsx
import React, { useState } from 'react';
import './App.css'; // Optional: for basic styling

function App() {
  const [phoneNumber, setPhoneNumber] = useState('');
  const [code, setCode] = useState('');
  const [verificationSid, setVerificationSid] = useState(null);
  const [verificationStatus, setVerificationStatus] = useState(''); // '', 'pending', 'verified', 'error'
  const [message, setMessage] = useState(''); // For success/error messages
  const [isLoading, setIsLoading] = useState(false);

  // Backend API URL – Ensure this matches your backend setup
  const API_URL = 'http://localhost:3001/api'; // Adjust port if needed

  const handleRequestOtp = async (e) => {
    e.preventDefault();
    setIsLoading(true);
    setMessage('');
    setVerificationStatus('');

    try {
      const response = await fetch(`${API_URL}/request-otp`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ phoneNumber }),
      });

      const data = await response.json();

      if (response.ok) {
        setVerificationSid(data.verificationSid);
        setVerificationStatus('pending');
        setMessage('OTP sent. Check your phone.');
      } else {
        throw new Error(data.error || `Request failed with status ${response.status}`);
      }
    } catch (error) {
      console.error('Request OTP Error:', error);
      setMessage(`Error requesting OTP: ${error.message}`);
      setVerificationStatus('error');
    } finally {
      setIsLoading(false);
    }
  };

  const handleVerifyOtp = async (e) => {
    e.preventDefault();
    if (!phoneNumber) {
        setMessage('No phone number found. Request OTP first.');
        return;
    }
    setIsLoading(true);
    setMessage('');

    try {
      const response = await fetch(`${API_URL}/verify-otp`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ phoneNumber, code }),
      });

      const data = await response.json();

       if (response.ok && data.verified) {
        setVerificationStatus('verified');
        setMessage(data.message || 'Phone number verified successfully.');
        // Reset form for potential next verification
        // setPhoneNumber('');
        // setCode('');
        // setVerificationSid(null);
      } else {
        // Use the error message from the backend if available
        throw new Error(data.error || `Verification failed with status ${response.status}`);
      }
    } catch (error) {
      console.error('Verify OTP Error:', error);
      setMessage(`Error: ${error.message}`);
      setVerificationStatus('error');
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div className="App">
      <h1>Phone Number Verification</h1>

      {!verificationStatus || verificationStatus === 'error' ? (
        <form onSubmit={handleRequestOtp}>
          <h2>Step 1: Enter Phone Number</h2>
          <p>Use E.164 format (e.g., +14155552671)</p>
          <label htmlFor="phoneNumber">Phone Number:</label>
          <input
            type="tel"
            id="phoneNumber"
            value={phoneNumber}
            onChange={(e) => setPhoneNumber(e.target.value)}
            placeholder="+14155552671"
            required
            disabled={isLoading}
          />
          <button type="submit" disabled={isLoading}>
            {isLoading ? 'Sending…' : 'Request OTP'}
          </button>
        </form>
      ) : null}

      {verificationStatus === 'pending' ? (
        <form onSubmit={handleVerifyOtp}>
          <h2>Step 2: Enter OTP Code</h2>
          <label htmlFor="code">Verification Code:</label>
          <input
            type="text" // Use "text" with pattern for better mobile input
            id="code"
            value={code}
            onChange={(e) => setCode(e.target.value)}
            required
            minLength="4"
            maxLength="10"
            pattern="\d*" // Allow only digits
            inputMode="numeric" // Hint for numeric keyboard on mobile
            disabled={isLoading}
          />
          <button type="submit" disabled={isLoading}>
            {isLoading ? 'Verifying…' : 'Verify Code'}
          </button>
        </form>
      ) : null}

      {message && (
        <p className={`message ${verificationStatus}`}>
          {message}
        </p>
      )}

       {verificationStatus === 'verified' && (
           <div>
               <h2>✓ Verification Complete</h2>
               {/* Optionally show verified number or allow next step */}
           </div>
       )}
    </div>
  );
}

export default App;

Optional Basic Styling – Place in src/App.css:

css
/* src/App.css */
.App { max-width: 400px; margin: 40px auto; padding: 20px; border: 1px solid #ccc; border-radius: 8px; text-align: center; }
label { display: block; margin-bottom: 5px; font-weight: bold; }
input[type="tel"], input[type="text"] { width: calc(100% - 22px); padding: 10px; margin-bottom: 15px; border: 1px solid #ccc; border-radius: 4px; }
button { padding: 10px 15px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1em; }
button:disabled { background-color: #ccc; cursor: not-allowed; }
.message { margin-top: 20px; padding: 10px; border-radius: 4px; }
.message.pending { background-color: #e7f3fe; color: #0c5460; border: 1px solid #bee5eb; }
.message.verified { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }

Explanation:

  1. State: Uses useState to manage the phone number input, OTP code input, the verificationSid received from the backend, loading state, UI status (verificationStatus), and feedback messages.
  2. API URL: Defines the base URL for the backend API.
  3. handleRequestOtp:
    • Triggered by the first form submission.
    • Sends a POST request to /api/request-otp with the phone number.
    • Uses fetch for the API call.
    • On success, stores the verificationSid and updates the UI to show the OTP input field.
    • On error, displays an error message.
    • Includes loading state management.
  4. handleVerifyOtp:
    • Triggered by the second form submission.
    • Sends a POST request to /api/verify-otp with the phone number and the entered code.
    • On success, sets the status to verified and shows a success message.
    • On error (including specific messages from the backend), updates the message state.
    • Includes loading state management.
  5. Conditional Rendering: The UI changes based on the verificationStatus:
    • Initially (or on error), shows the phone number input form.
    • When pending, shows the OTP code input form.
    • When verified, shows a success confirmation.
    • Displays messages based on API responses.

4. Testing Your SMS OTP Authentication System

  1. Start the Backend:

    bash
    cd backend
    npm start # Or node server.js

    The API server starts on http://localhost:3001.

  2. Start the Frontend:

    bash
    cd ../frontend
    npm run dev

    The Vite dev server starts on http://localhost:5173.

  3. Test:

    • Open your browser to http://localhost:5173.
    • Enter your phone number in E.164 format (e.g., +14155552671).
    • Click Request OTP. Check your phone for an SMS with the code (may take a few seconds).
    • Enter the received code.
    • Click Verify Code.
    • You should see the success message. Check the backend console logs for details.

5. Implementing Error Handling and Logging

  • Backend: The server.js includes try...catch blocks for Twilio API calls. It logs errors to the console and returns meaningful JSON error responses with appropriate HTTP status codes (400, 404, 410, 429, 500). Twilio-specific error codes are mapped to clearer user messages. For production, integrate a dedicated logging library (like Winston or Pino) and an error tracking service (like Sentry).
  • Frontend: The App.jsx catches errors from fetch calls and displays error messages returned from the backend API.

The provided example uses an in-memory object (verificationRequests), which is unsuitable for production. Integrate a database.

Schema Example – You'd need a table (e.g., phone_verifications) with:

  • verification_id (Primary Key)
  • user_id (Foreign Key to your users table, if applicable)
  • phone_number (The number being verified)
  • twilio_verification_sid (The SID from Twilio, indexed for lookups)
  • status ('pending', 'verified', 'failed', 'expired', 'cancelled')
  • created_at (Timestamp)
  • expires_at (Timestamp, e.g., 10 minutes from created_at)
  • attempts (Integer, track incorrect code entries)

Data Layer Functions:

  • Create a new verification record when /request-otp is called.
  • Find a record by twilio_verification_sid during /verify-otp.
  • Update the record's status and attempt count.
  • Periodically clean up expired records.

Technology Options: Redis is excellent for short-lived data like OTP requests due to its speed and TTL features. Alternatively, use your main application database (PostgreSQL, MySQL, MongoDB).


7. Security Best Practices for OTP Authentication

  • Input Validation: The backend validates phone number format (E.164) and OTP code format. For production, use a dedicated library like libphonenumber-js for comprehensive phone number validation instead of basic regex. Sanitize all inputs.
  • Rate Limiting: Implement rate limiting on both /request-otp (e.g., 1 request per minute per number/IP) and /verify-otp (e.g., 5 attempts per verification). Use libraries like express-rate-limit. Twilio also applies its own rate limits.
  • Secure API Keys: Never commit .env files or hardcode keys. Use environment variables in your deployment environment.
  • HTTPS: Always use HTTPS in production for both frontend and backend.
  • Verification SID Handling: Don't expose internal database IDs. Use the opaque twilio_verification_sid for verification checks.
  • CSRF Protection: If your frontend uses cookies/sessions for authentication with the backend, implement CSRF protection (e.g., using csurf middleware). This is less critical with token-based authentication (like JWTs) stored in localStorage/sessionStorage, but still good practice.

8. Handling Edge Cases in SMS OTP Verification

  • Phone Number Formatting: Always normalize numbers to E.164 format (+ followed by country code and number) before sending to Twilio.
  • OTP Expiry: Twilio handles expiry (typically 10 minutes). Your UI should reflect this or allow requesting a new code after timeout. The backend handles expired codes.
  • Concurrent Requests: Twilio prevents sending multiple codes too quickly to the same number. A production implementation should catch this error, inform the user, or manage the flow via database state.
  • Network Errors: Implement retries with exponential backoff for transient network issues when calling the Twilio API. The frontend uses fetch – implement this manually or use a fetch wrapper library that supports retries.

9. Performance Optimization Tips

  • Database Indexing: Index the twilio_verification_sid column in your database table for fast lookups during verification.
  • Asynchronous Operations: Node.js is inherently non-blocking. Ensure all I/O operations (database calls, Twilio API calls) use async/await or Promises correctly to avoid blocking the event loop.
  • Efficient Code: Keep API logic concise and avoid unnecessary computations.
  • Caching: Caching isn't typically applied directly to the OTP flow itself, but ensure general API performance best practices are followed.

10. Monitoring and Analytics for OTP Systems

Logging: Implement structured logging (JSON format) in the backend. Log key events:

  • OTP request initiation, success, failure (with error details)
  • Verification attempts
  • Include correlation IDs (like verificationSid) in logs

Health Checks: The /health endpoint provides a basic check. Expand this to verify database connectivity and Twilio API reachability if possible.

Metrics to Track:

  • Number of OTPs requested/verified/failed
  • API endpoint latency (/request-otp, /verify-otp)
  • Error rates (HTTP 4xx/5xx)
  • Twilio API error rates/types
  • Use tools like Prometheus/Grafana or Datadog

Error Tracking: Integrate services like Sentry or Bugsnag to capture and alert on backend exceptions and frontend errors.

Dashboards: Create dashboards visualizing key metrics to monitor the health and usage of the OTP system.


11. Common Troubleshooting Issues

  • Twilio API Keys Invalid (Error: 401 Unauthorized): Double-check keys in .env and ensure dotenv.config() is called early.
  • Invalid Phone Number Format (Twilio error or backend 400): Ensure E.164 format (+1…). Use validation libraries.
  • Rate Limited / Throttled (Twilio error or HTTP 429): Requests sent too frequently. Implement backend rate limiting and inform the user. Check Twilio account limits.
  • Incorrect Code (Backend 400): User entered wrong code. Handled in the /verify-otp logic.
  • Expired Code (Backend 410): Code expired (usually 10 minutes). User needs to request a new code.
  • Too Many Wrong Attempts (Twilio error or backend 429): User tried wrong code too many times. Invalidate the request and force requesting a new one.
  • CORS Errors (Browser console error): Ensure cors middleware in server.js is configured correctly, especially the origin option (use * for testing, but restrict to your frontend domain in production).
  • .env Not Loading: Ensure dotenv.config() is called at the very top of server.js before accessing process.env variables.
  • In-Memory Store Limitations: The demo store will lose data on server restarts. Do not use in production.
  • Twilio Service Issues: Check the Twilio Status Page if you suspect an outage.

12. Production Deployment Checklist

Environment Variables: Configure TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_VERIFY_SERVICE_SID, PORT, and the production frontend origin URL for CORS securely in your deployment environment using platform secrets management.

Database: Provision and configure your chosen production database (e.g., managed Redis, PostgreSQL).

Build Process:

  • Frontend: Run npm run build in the frontend directory. Serve the static files from the dist folder using a static file server (like Nginx, Vercel, Netlify, or integrated into your backend).
  • Backend: Ensure Node.js is installed on the server. Use a process manager like PM2 to keep the Node.js server running.

HTTPS: Configure HTTPS for both frontend and backend traffic.

CI/CD Pipeline: Set up a pipeline (e.g., GitHub Actions, GitLab CI, Jenkins) to automate testing, building, and deploying the frontend and backend.



Next Steps: Integrate this OTP verification system with your existing authentication flow, add rate limiting for API protection, and consider implementing multi-factor authentication with authenticator apps as an additional security layer.

Frequently Asked Questions

How to implement SMS OTP verification in Node.js?

Use Express for the backend, Vite with React (or Vue) for the frontend, and the Vonage Verify API for sending and verifying OTPs. This setup provides a secure way to verify user phone numbers, adding 2FA for logins or other critical actions.

What is the Vonage Verify API used for?

The Vonage Verify API simplifies OTP implementation by handling OTP generation, delivery via SMS or voice, and the verification process. This offloads the complex security logic to a dedicated service.

Why use 2FA with SMS OTP for web applications?

2FA adds a crucial security layer by requiring users to verify their identity with something they know (password) and something they have (phone). This helps prevent unauthorized access and automated attacks.

When should I implement OTP verification in my app?

Implement OTP verification for actions requiring high security, such as login, password resets, transaction confirmations, or sensitive profile changes. This helps protect user accounts and data.

Can I use Vue.js instead of React for the frontend?

Yes, the provided frontend code using Vite is easily adaptable to Vue.js or other frontend frameworks. The core logic for interacting with the backend API remains the same.

How to request an OTP with the Vonage API?

Send a POST request to the `/api/request-otp` endpoint with the user's phone number in E.164 format (e.g., +14155552671). The backend will interact with the Vonage API and return a `request_id`.

What is the purpose of the request ID in OTP verification?

The `request_id`, returned by the Vonage API after requesting an OTP, is a unique identifier for that verification attempt. It's used to verify the OTP code entered by the user against the correct request.

How to verify an OTP code entered by the user?

Send a POST request to the `/api/verify-otp` endpoint, providing the `request_id` and the user-entered OTP code. The backend will check the code against the Vonage Verify API and return the verification result.

What is the correct phone number format for Vonage Verify API?

Use the E.164 format, which includes a plus sign (+) followed by the country code and phone number (e.g., +14155552671). Ensure consistent formatting to avoid errors.

Why is the in-memory store not suitable for production?

The in-memory store (the `verificationRequests` object) is only for demonstration purposes. In production, use a persistent database like Redis or PostgreSQL to store request IDs and associate them with users.

What are important security considerations for OTP verification?

Implement input validation, rate limiting, secure API key storage, HTTPS, and proper request ID handling to prevent abuse and ensure the security of your OTP system. Consider CSRF protection as well.

How to handle expired OTP codes or request IDs?

The backend handles expired codes and request IDs (Vonage status '6'). The frontend should allow users to request a new code after a timeout or if an error indicates expiry. Proper error messages guide users.

What should I do if the Vonage API keys are invalid?

Double-check your `.env` file to ensure the `VONAGE_API_KEY` and `VONAGE_API_SECRET` are correct and that the `.env` file is loaded properly using `dotenv.config()` early in your backend code.

How to troubleshoot CORS errors during development?

Ensure the `cors` middleware in your backend's `server.js` file is set up correctly, especially the `origin` option. During development, set it to your frontend's development URL. In production, configure to match frontend domain.