code examples

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

Implement Node.js Express OTP/2FA SMS with Vonage Verify API

A guide on implementing SMS-based OTP/2FA in a Node.js Express application using the Vonage Verify API, covering setup, API interaction, and error handling.

This guide provides a complete walkthrough for implementing One-Time Password (OTP) or Two-Factor Authentication (2FA) via SMS in a Node.js application using Express and the Vonage Verify API. We will build a simple web application that requests a user's phone number, sends a verification code via SMS using Vonage, and then verifies the code entered by the user.

This approach simplifies the complexities of OTP generation, delivery retries (including voice call fallbacks), code expiry, and verification checks by leveraging the robust features of the Vonage Verify API.

Project Overview and Goals

What We're Building:

  • A Node.js web application using the Express framework.
  • A simple frontend with three stages:
    1. Enter phone number.
    2. Enter the received OTP code.
    3. Success/Failure indication.
  • Backend logic to interact with the Vonage Verify API for sending and checking OTP codes via SMS.

Problem Solved: Securely verifying user identity or actions by confirming possession of a specific phone number, a common requirement for sign-ups, logins, or sensitive transactions.

Technologies Used:

  • Node.js: JavaScript runtime environment.
  • Express: Minimalist web framework for Node.js.
  • Vonage Server SDK for Node.js (@vonage/server-sdk): Official library to interact with Vonage APIs.
  • Vonage Verify API: Handles the OTP lifecycle (generation, sending via SMS/voice, checking).
  • EJS: Templating engine for rendering simple HTML views.
  • dotenv: Module to load environment variables from a .env file.
  • body-parser: Middleware to parse incoming request bodies.
  • express-validator: Middleware for input validation.

Architecture:

The flow is straightforward:

  1. User: Accesses the web application via a browser.
  2. Express App (Node.js):
    • Serves the HTML pages (EJS templates).
    • Receives user input (phone number, OTP code).
    • Communicates with the Vonage Verify API using the Vonage SDK.
    • Renders appropriate responses based on API results.
  3. Vonage Verify API:
    • Receives requests from the Express app.
    • Generates and sends OTP codes via SMS (or voice fallback).
    • Manages code validity and retries.
    • Verifies submitted codes against its records.
    • Returns verification results to the Express app.

(Diagrammatically):

[Browser] <--> [Express App (Node.js + Vonage SDK)] <--> [Vonage Verify API] --> [User's Phone (SMS)]

Prerequisites:

Expected Outcome: A functional web application demonstrating the OTP/2FA flow using SMS verification powered by Vonage.


1. Setting up the Project

Let's initialize our Node.js project and install the necessary dependencies.

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

    bash
    mkdir vonage-otp-express
    cd vonage-otp-express
  2. Initialize npm: Create a package.json file to manage project dependencies and scripts:

    bash
    npm init -y
  3. Install Dependencies: Install Express, the Vonage SDK, EJS for templating, dotenv for environment variables, body-parser for handling form data, and express-validator for input validation:

    bash
    npm install express @vonage/server-sdk dotenv ejs body-parser express-validator
    • express: Web application framework.
    • @vonage/server-sdk: To interact with Vonage APIs.
    • dotenv: Loads environment variables from a .env file into process.env. Crucial for keeping secrets out of code.
    • ejs: Simple templating engine for rendering HTML views with dynamic data.
    • body-parser: Middleware needed to parse JSON and URL-encoded request bodies (like form submissions).
    • express-validator: Middleware for validating incoming request data (like phone numbers and codes).
  4. Create Project Structure: Set up a basic structure for organization:

    bash
    mkdir views public
    touch index.js .env .gitignore
    • views/: Directory to store our EJS template files (.ejs).
    • public/: Directory for static assets like CSS or client-side JavaScript (optional for this guide).
    • index.js: Main application file where our Express server logic will reside.
    • .env: File to store sensitive credentials like API keys (will be ignored by Git).
    • .gitignore: Specifies intentionally untracked files that Git should ignore.
  5. Configure .gitignore: Add node_modules and .env to your .gitignore file to prevent committing dependencies and secrets:

    text
    # .gitignore
    
    node_modules/
    .env
  6. Configure Environment Variables (.env): Open the .env file and add your Vonage API Key and Secret.

    How to get Vonage API Key and Secret: a. Log in to your Vonage API Dashboard. b. Your API Key and API Secret are displayed prominently on the main dashboard page under "API settings". c. Copy these values into your .env file.

    dotenv
    # .env
    
    VONAGE_API_KEY=YOUR_API_KEY
    VONAGE_API_SECRET=YOUR_API_SECRET
    VONAGE_BRAND_NAME=YourAppName # Optional: Customize the brand name in the SMS message

    Replace YOUR_API_KEY and YOUR_API_SECRET with your actual credentials. VONAGE_BRAND_NAME will be used in the SMS message ("YourAppName code is 1234").


2. Implementing Core Functionality (Verify API Workflow)

Now, let's build the Express application logic and the views for our OTP flow.

  1. Initialize Express and Vonage SDK (index.js): Open index.js and set up the basic Express app, configure EJS, load environment variables, initialize the Vonage SDK, and require express-validator.

    javascript
    // index.js
    require('dotenv').config(); // Load .env variables into process.env
    const express = require('express');
    const bodyParser = require('body-parser');
    const { Vonage } = require('@vonage/server-sdk');
    const { check, validationResult } = require('express-validator'); // For input validation
    
    const app = express();
    const port = process.env.PORT || 3000;
    
    // Initialize Vonage SDK
    // IMPORTANT: Ensure VONGAGE_API_KEY and VONAGE_API_SECRET are in your .env file
    if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) {
        console.error('Error: VONAGE_API_KEY and VONAGE_API_SECRET must be set in .env file.');
        process.exit(1); // Exit if keys are missing
    }
    
    const vonage = new Vonage({
        apiKey: process.env.VONAGE_API_KEY,
        apiSecret: process.env.VONAGE_API_SECRET
    });
    
    // Middleware Setup
    app.set('view engine', 'ejs'); // Set EJS as the template engine
    app.use(bodyParser.json()); // Parse JSON bodies
    app.use(bodyParser.urlencoded({ extended: true })); // Parse URL-encoded bodies (form submissions)
    app.use(express.static(__dirname + '/public')); // Serve static files (optional)
    
    // --- Routes will go here ---
    
    // Start the server
    app.listen(port, () => {
        console.log(`Server listening at http://localhost:${port}`);
    });
    • require('dotenv').config(): Loads variables from .env. Must be called early.
    • We initialize Vonage with the API key and secret from process.env.
    • Basic check added to ensure keys are present before proceeding.
    • app.set('view engine', 'ejs'): Tells Express to use EJS for rendering files from the views directory.
    • bodyParser middleware is configured to handle form submissions.
    • express-validator functions (check, validationResult) are imported.
  2. Page 1: Request Verification Code (View and Routes):

    • Create View (views/index.ejs): This page will contain the form to enter the phone number. It includes a placeholder for displaying messages and pre-fills the number if an error occurred.

      html
      <!-- views/index.ejs -->
      <!DOCTYPE html>
      <html>
      <head>
          <title>Enter Phone Number</title>
          <!-- Add basic styling if desired -->
      </head>
      <body>
          <h1>Enter Your Phone Number for Verification</h1>
      
          <% if (typeof message !== 'undefined' && message) { %>
              <p style=""color: <%= messageType === 'error' ? 'red' : 'green' %>;""><%= message %></p>
          <% } %>
      
          <form method=""POST"" action=""/request-verification"">
              <label for=""number"">Phone Number (e.g., 14155552671):</label><br>
              <input type=""tel"" id=""number"" name=""number"" required placeholder=""Include country code"" value=""<%= typeof phoneNumber !== 'undefined' ? phoneNumber : '' %>""><br><br>
              <button type=""submit"">Send Verification Code</button>
          </form>
      </body>
      </html>
      • It displays a message if passed from the server (used for success/error feedback).
      • The form POSTs to the /request-verification endpoint.
      • The value attribute of the input field is set to phoneNumber if it's passed to the template (useful on error).
      • We recommend instructing users to include the country code (E.164 format is best for Vonage, e.g., 14155552671 for US).
    • Create GET Route / (index.js): This route simply renders the index.ejs view when the user visits the root URL. Add this inside index.js before app.listen():

      javascript
      // index.js (continued)
      
      // --- Routes ---
      app.get('/', (req, res) => {
          // Render the initial page with the phone number form
          res.render('index', { message: null, phoneNumber: null }); // Pass null message and number initially
      });
      
      // --- More routes below ---
    • Create POST Route /request-verification (index.js): This route handles the form submission from index.ejs. It takes the phone number, calls the Vonage Verify API to start the verification process, and then renders the next page (verify.ejs) for code entry. If an error occurs, it re-renders the index.ejs page with the error message and the previously entered phone number.

      javascript
      // index.js (continued)
      
      app.post('/request-verification', async (req, res) => {
          const phoneNumber = req.body.number;
          const brand = process.env.VONAGE_BRAND_NAME || ""MyApp""; // Use brand from .env or default
      
          // Basic validation: Check if number exists
          if (!phoneNumber) {
              return res.render('index', {
                  message: 'Phone number is required.',
                  messageType: 'error',
                  phoneNumber: phoneNumber // Pass back the (empty) number
              });
          }
      
          console.log(`Requesting verification for number: ${phoneNumber}`);
      
          try {
              const response = await vonage.verify.start({
                  number: phoneNumber,
                  brand: brand
                  // workflow_id: 6 // Optional: To use SMS only workflow. Default includes SMS -> TTS -> TTS
              });
      
              console.log('Vonage Verify Start Response:', response);
      
              if (response.status === '0') {
                  // Successfully started verification
                  // Render the page to enter the code, passing the request_id and phone number
                  res.render('verify', {
                      requestId: response.request_id,
                      phoneNumber: phoneNumber, // Pass number for display/resend/cancel
                      message: 'Verification code sent. Please check your phone.',
                      messageType: 'success'
                  });
              } else {
                  // Handle Vonage API errors
                  console.error('Vonage Verify Start Error:', response.error_text);
                  res.render('index', {
                      message: `Error starting verification: ${response.error_text}`,
                      messageType: 'error',
                      phoneNumber: phoneNumber // Pass number back to the form
                  });
              }
          } catch (error) {
              // Handle network or other unexpected errors
              console.error('Error calling Vonage Verify API:', error);
              res.render('index', {
                  message: 'An unexpected error occurred. Please try again later.',
                  messageType: 'error',
                  phoneNumber: phoneNumber // Pass number back to the form
              });
          }
      });
      
      // --- More routes below ---
      • It retrieves the number from the request body (req.body).
      • It calls vonage.verify.start().
      • Success (response.status === '0'): It renders verify.ejs (created next), passing the crucial request_id and the phoneNumber.
      • Failure: It renders index.ejs again with an error message and the phoneNumber to repopulate the form.
      • A try...catch block handles potential network errors during the API call.
  3. Page 2: Check Verification Code (View and Route):

    • Create View (views/verify.ejs): This page allows the user to enter the code they received via SMS. It includes hidden fields to pass the request_id and phoneNumber back to the server.

      html
      <!-- views/verify.ejs -->
      <!DOCTYPE html>
      <html>
      <head>
          <title>Enter Verification Code</title>
      </head>
      <body>
          <h1>Enter Verification Code</h1>
          <p>Sent to: <%= phoneNumber %></p> <%# Display the number for confirmation %>
      
          <% if (typeof message !== 'undefined' && message) { %>
              <p style=""color: <%= messageType === 'error' ? 'red' : 'green' %>;""><%= message %></p>
          <% } %>
      
          <form method=""POST"" action=""/check-verification"">
              <input type=""hidden"" name=""requestId"" value=""<%= requestId %>""> <%# Crucial hidden field %>
              <input type=""hidden"" name=""phoneNumber"" value=""<%= phoneNumber %>""> <%# Pass phone number back %>
      
              <label for=""code"">Verification Code:</label><br>
              <input type=""text"" id=""code"" name=""code"" required minlength=""4"" maxlength=""6""><br><br> <%# OTPs are typically 4-6 digits %>
      
              <button type=""submit"">Verify Code</button>
          </form>
      
          <!-- Optional: Add a cancel button/link here -->
          <form method=""POST"" action=""/cancel-verification"" style=""margin-top: 10px;"">
               <input type=""hidden"" name=""requestId"" value=""<%= requestId %>"">
               <input type=""hidden"" name=""phoneNumber"" value=""<%= phoneNumber %>"">
               <button type=""submit"">Cancel Verification</button>
          </form>
          <!-- Optional: Add a resend button/link here (would likely POST to /request-verification again) -->
      
      </body>
      </html>
      • It displays the phoneNumber it was sent to.
      • It uses hidden inputs to send both requestId and phoneNumber back. This is essential for Vonage to check the correct attempt and for re-rendering the page on failure.
      • The form POSTs to the /check-verification endpoint.
      • Includes an example ""Cancel Verification"" form posting to /cancel-verification.
    • Create POST Route /check-verification (index.js): This route handles the submission from verify.ejs. It receives the requestId, code, and phoneNumber, then calls the Vonage Verify API to check the code. If the check fails, it re-renders verify.ejs with the context.

      javascript
      // index.js (continued)
      
      app.post('/check-verification', async (req, res) => {
          const requestId = req.body.requestId;
          const code = req.body.code;
          const phoneNumber = req.body.phoneNumber; // Retrieve phone number passed from hidden input
      
          // Basic validation
          if (!requestId || !code || !phoneNumber) {
               return res.render('verify', { // Re-render verify page with error
                  requestId: requestId,
                  phoneNumber: phoneNumber, // Pass back what we received
                  message: 'Missing information. Please try entering the code again.',
                  messageType: 'error'
              });
          }
      
          console.log(`Checking verification for request ID: ${requestId} with code: ${code}`);
      
          try {
              const response = await vonage.verify.check(requestId, code);
      
              console.log('Vonage Verify Check Response:', response);
      
              if (response.status === '0') {
                  // Verification Successful!
                  // Render the success page
                  res.render('success', { message: 'Phone number verified successfully!' });
                  // In a real app: Mark user as verified, log them in, proceed with action, etc.
              } else {
                  // Verification Failed
                  // Render the verify page again with an error message
                  res.render('verify', {
                      requestId: requestId,
                      phoneNumber: phoneNumber, // Pass number back for display and hidden field
                      message: `Verification failed: ${response.error_text} (Status: ${response.status})`,
                      messageType: 'error'
                  });
              }
          } catch (error) {
              // Handle network or other unexpected errors
              console.error('Error calling Vonage Check API:', error);
              res.render('verify', { // Re-render verify page
                  requestId: requestId,
                  phoneNumber: phoneNumber, // Pass number back
                  message: 'An unexpected error occurred during verification. Please try again.',
                  messageType: 'error'
              });
          }
      });
      
      // --- Success page route below ---
      • It retrieves requestId, code, and crucially phoneNumber from req.body.
      • It calls vonage.verify.check().
      • Success (response.status === '0'): Renders success.ejs (created next).
      • Failure: Renders verify.ejs again, passing back the requestId, phoneNumber, and displaying an error message.
      • Includes try...catch for network errors.
  4. Page 3: Success Page (View):

    • Create View (views/success.ejs): A simple confirmation page shown after successful verification.

      html
      <!-- views/success.ejs -->
      <!DOCTYPE html>
      <html>
      <head>
          <title>Verification Successful</title>
      </head>
      <body>
          <h1>Success!</h1>
          <% if (typeof message !== 'undefined' && message) { %>
              <p style=""color: green;""><%= message %></p>
          <% } %>
          <p>Your phone number has been successfully verified.</p>
          <!-- Add a link back to the start or to the next step in your application -->
          <a href=""/"">Start Over</a>
      </body>
      </html>

3. Building a Complete API Layer

While this example is a simple web application, the Express routes (/request-verification, /check-verification) effectively act as an API layer if you were building a backend service for a separate frontend (like a React or Vue app) or mobile application.

Considerations for a dedicated API:

  • Authentication/Authorization: Protect these endpoints if they are part of a larger system. Ensure only authenticated users can initiate verification for their own associated phone numbers. This typically involves session management or token-based authentication (e.g., JWT).
  • Request Validation: Implement robust validation using libraries like express-validator to check input formats (e.g., ensuring the phone number follows E.164, the code is numeric and has the expected length).
    javascript
    // Example using express-validator in index.js routes
    
    // Add validation middleware to the /request-verification route
    app.post('/request-verification',
        check('number').isMobilePhone('any', { strictMode: false }).withMessage('Invalid phone number format.'), // Basic phone format check
        async (req, res) => {
            const errors = validationResult(req);
            const phoneNumber = req.body.number; // Get phone number for re-rendering on error
            if (!errors.isEmpty()) {
                return res.status(400).render('index', { // Use 400 status for bad request
                     message: errors.array()[0].msg, // Show first validation error
                     messageType: 'error',
                     phoneNumber: phoneNumber // Pass number back
                });
            }
            // ... rest of the route logic from Section 2 ...
        }
    );
    
     // Add validation middleware to the /check-verification route
     app.post('/check-verification',
        check('code').isLength({ min: 4, max: 6 }).isNumeric().withMessage('Invalid code format.'),
        check('requestId').notEmpty().withMessage('Request ID is missing.'),
        check('phoneNumber').notEmpty().withMessage('Phone number is missing.'), // Also check hidden field
         async (req, res) => {
            const errors = validationResult(req);
            const requestId = req.body.requestId; // Get these to re-render verify page correctly
            const phoneNumber = req.body.phoneNumber;
            if (!errors.isEmpty()) {
                return res.status(400).render('verify', {
                     requestId: requestId,
                     phoneNumber: phoneNumber,
                     message: errors.array()[0].msg,
                     messageType: 'error'
                 });
             }
            // ... rest of the route logic from Section 2 ...
         }
     );
    (Remember: express-validator was installed in Section 1, Step 3)
  • API Documentation: Use tools like Swagger/OpenAPI to document endpoints, request/response formats (JSON), and status codes if building a true API.
  • Testing with curl or Postman:
    • Request Verification:
      bash
      curl -X POST http://localhost:3000/request-verification \
           -H "Content-Type: application/x-www-form-urlencoded" \
           -d "number=YOUR_PHONE_NUMBER"
      # Replace YOUR_PHONE_NUMBER with a valid E.164 number
      # Expect HTML response rendering the verify page (if successful)
    • Check Verification: (Requires a valid requestId and phoneNumber from a previous step)
      bash
      curl -X POST http://localhost:3000/check-verification \
           -H "Content-Type: application/x-www-form-urlencoded" \
           -d "requestId=YOUR_REQUEST_ID&code=YOUR_OTP_CODE&phoneNumber=YOUR_PHONE_NUMBER"
      # Replace YOUR_REQUEST_ID, YOUR_OTP_CODE, and YOUR_PHONE_NUMBER
      # Expect HTML response rendering success or verify page

4. Integrating with Necessary Third-Party Services (Vonage)

  • Configuration: Done via the .env file (VONAGE_API_KEY, VONAGE_API_SECRET, VONAGE_BRAND_NAME).
  • API Keys/Secrets Security:
    • Use dotenv to load keys from .env.
    • Crucially: Add .env to your .gitignore file to prevent accidentally committing secrets to version control.
    • On deployment platforms (Heroku, AWS, etc.), use their mechanisms for setting environment variables securely – do not store .env files on production servers.
  • Dashboard Navigation for Credentials:
    1. Go to the Vonage API Dashboard.
    2. Log in to your account.
    3. The API Key and API Secret are displayed directly on the landing page under the ""API settings"" section. Copy these values.
  • Fallback Mechanisms: The Vonage Verify API handles fallbacks automatically within its defined workflows (e.g., SMS -> Voice Call). You generally don't need to implement fallback logic in your application for OTP delivery, unless you want to offer alternative 2FA methods entirely (like authenticator apps).

5. Implementing Proper Error Handling, Logging, and Retry Mechanisms

  • Error Handling Strategy:
    • Use try...catch blocks around all Vonage API calls to handle network issues or unexpected exceptions.
    • Check the status property in the Vonage API response. status === '0' indicates success. Any other status indicates an error specific to the API call (e.g., invalid number, wrong code, insufficient funds).
    • Use the error_text property from the Vonage response for specific error details.
    • Provide user-friendly error messages on the frontend (don't expose raw API error details unless necessary for debugging). Pass error messages, types, and necessary context (like phoneNumber, requestId) to the EJS templates for re-rendering forms correctly.
    • Use appropriate HTTP status codes for API-style responses (e.g., 400 for bad input, 500 for server errors, 429 for rate limiting). Even in this web app, setting res.status(400) before rendering an error page due to invalid input (like in the express-validator example) is good practice.
  • Logging:
    • Use console.log for basic informational messages during development (e.g., which number is being verified).
    • Use console.error for logging errors caught in catch blocks or when Vonage API status is non-zero. Include the Vonage error_text and status code in the log.
    • Production Logging: For production, replace console.log/console.error with a dedicated logging library like Winston or Pino. These offer structured logging (JSON), different log levels (info, warn, error), and transport options (writing to files, external services).
    javascript
    // Example: Logging Vonage errors
    if (response.status !== '0') {
        console.error(`Vonage API Error: Status ${response.status}, Text: ${response.error_text}, RequestID: ${response.request_id || requestId || 'N/A'}`);
        // Render error page...
    }
  • Retry Mechanisms:
    • Vonage Handles Delivery Retries: The Verify API automatically handles retrying SMS delivery and falling back to voice calls based on the selected workflow. You don't need to implement retries for sending the OTP.
    • Application-Level Retries: You might implement retries in your catch blocks for transient network errors when calling the Vonage API, potentially using exponential backoff. However, for OTP verification, it's often simpler to report the error and let the user try again manually.
    • User-Initiated Resend: Consider adding a "Resend Code" button on the verify.ejs page. This button would trigger the /request-verification route again with the same phone number. Implement rate limiting on this feature.

6. Creating a Database Schema and Data Layer

For this specific implementation using the Vonage Verify API, no database schema is required on your end to manage the OTP state. Vonage handles:

  • Generating the code.
  • Storing the code securely.
  • Tracking the request_id.
  • Managing expiry times.
  • Recording verification attempts.

You would need a database if:

  • You were building the OTP logic manually using the Vonage Messages API. You'd need to store the generated OTP, its expiry timestamp, the associated user/phone number, and its verification status.
  • You need to link the verified phone number to a user account in your application's main database. You'd typically have a users table with columns like id, email, password_hash, phone_number, is_phone_verified (boolean), etc. After a successful verification via /check-verification, you would update the is_phone_verified flag for the corresponding user.

7. Adding Security Features

  • Input Validation and Sanitization:
    • Use express-validator (shown in Section 3) or similar libraries to validate phone number format and code format/length. This prevents invalid data from reaching the Vonage API and reduces error handling complexity.
    • Sanitization (removing potentially harmful characters) is less critical for phone numbers and numeric codes but is vital for other user inputs in a larger application.
  • Protection Against Common Vulnerabilities:
    • Cross-Site Scripting (XSS): EJS automatically escapes HTML content by default when using <%= ... %>, providing basic XSS protection for data displayed in views. Be cautious if using <%- ... %> (unescaped output).
    • Cross-Site Request Forgery (CSRF): For web applications with sessions, use CSRF tokens (e.g., with the csurf middleware) to prevent malicious sites from forcing users to submit requests to your application. Less critical for stateless APIs using tokens but still good practice if sessions are used.
  • Rate Limiting: Essential to prevent abuse and control costs.
    • Apply rate limiting to the /request-verification endpoint to prevent users from spamming phone numbers with codes.
    • Apply rate limiting to the /check-verification endpoint to prevent brute-force guessing of OTP codes.
    • Use middleware like express-rate-limit.
    bash
    npm install express-rate-limit
    javascript
    // index.js - Apply rate limiting
    const rateLimit = require('express-rate-limit');
    
    const requestVerificationLimiter = rateLimit({
        windowMs: 15 * 60 * 1000, // 15 minutes
        max: 5, // Limit each IP to 5 verification requests per windowMs
        message: 'Too many verification 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
        // Store configuration can be added for distributed environments
    });
    
    const checkVerificationLimiter = rateLimit({
        windowMs: 5 * 60 * 1000, // 5 minutes
        max: 10, // Limit each IP to 10 check attempts per windowMs (allows for typos)
        message: 'Too many code check attempts from this IP, please try again after 5 minutes',
        standardHeaders: true,
        legacyHeaders: false,
    });
    
    // Apply to specific routes *before* the route handlers
    app.use('/request-verification', requestVerificationLimiter);
    app.use('/check-verification', checkVerificationLimiter);
    // If cancel route is heavily used, apply rate limiting there too
    // app.use('/cancel-verification', checkVerificationLimiter); // Example
    
    // ... (rest of your routes)
  • Brute Force Protection: The Vonage Verify API has built-in mechanisms (like limiting check attempts per request_id). Server-side rate limiting (above) adds another layer.
  • Secure Key Storage: Reiterating: Use .env and .gitignore locally, and secure environment variable management in production. Never hardcode API keys/secrets.

8. Handling Special Cases Relevant to the Domain

  • International Phone Numbers: Always aim to collect numbers in E.164 format (e.g., +14155552671, although Vonage often handles numbers without the +). Inform users clearly about the required format. Use frontend libraries (like libphonenumber-js) for input formatting and validation if possible.
  • Number Formatting: Be prepared for users entering numbers with spaces, dashes, or parentheses. Sanitize the input on the backend before sending it to Vonage (e.g., remove all non-numeric characters except a leading + if using strict E.164). express-validator's isMobilePhone can help, but additional stripping of characters might be needed.
  • SMS Delivery Issues: SMS is generally reliable but not guaranteed. Vonage's fallback to voice calls helps mitigate this. Inform users that delivery might take a moment and provide a ""Resend Code"" option (with rate limiting).
  • Code Expiry: Vonage manages code expiry (typically 5 minutes). If a user enters an expired code, the /check-verification call will fail with an appropriate error (status 16: ""The code provided does not match the expected value""). Your error handling should inform the user the code expired and prompt them to request a new one.
  • Cancel Verification: The Vonage Verify API provides a cancel endpoint (vonage.verify.cancel(requestId)). Implement a route (e.g., /cancel-verification) triggered by a ""Cancel"" button on the verify.ejs page. This invalidates the request_id and prevents further checks against it.
    javascript
    // index.js - Add Cancel Route
    app.post('/cancel-verification', async (req, res) => {
        const requestId = req.body.requestId;
        const phoneNumber = req.body.phoneNumber; // Get phone number for potential redirect/message
    
        if (!requestId) {
            // Redirect back to start or show generic error
            console.error('Cancel attempt without request ID.');
            return res.redirect('/');
        }
    
        console.log(`Cancelling verification for request ID: ${requestId}`);
    
        try {
            const response = await vonage.verify.cancel(requestId);
            console.log('Vonage Verify Cancel Response:', response);
    
            if (response.status === '0') {
                // Successfully cancelled
                res.render('index', { // Render start page with message
                    message: 'Verification cancelled.',
                    messageType: 'info',
                    phoneNumber: null // Clear phone number field
                });
            } else if (response.status === '19') {
                 // Status 19: Verification request cannot be cancelled now (already verified or expired)
                 console.warn(`Attempted to cancel already completed/invalid request: ${requestId}`);
                 res.render('index', {
                    message: 'Verification process already completed or expired.',
                    messageType: 'warn',
                    phoneNumber: null
                });
            }
            else {
                // Handle other Vonage API errors during cancel
                console.error('Vonage Verify Cancel Error:', response.error_text);
                // Maybe redirect back to verify page with error? Or start page?
                res.render('verify', { // Re-render verify page with cancel error
                    requestId: requestId,
                    phoneNumber: phoneNumber,
                    message: `Error cancelling verification: ${response.error_text}`,
                    messageType: 'error'
                });
            }
        } catch (error) {
            // Handle network or other unexpected errors
            console.error('Error calling Vonage Cancel API:', error);
            res.render('verify', { // Re-render verify page with generic error
                requestId: requestId,
                phoneNumber: phoneNumber,
                message: 'An unexpected error occurred while cancelling. Please try again.',
                messageType: 'error'
            });
        }
    });
  • User Account Linking: In a real application, after successful verification (/check-verification success), you need logic to associate the verified phone number with the correct user account in your database. This might involve looking up the user by an ID stored in the session or passed via a token.

9. Testing the Implementation

  • Unit Tests: Test individual functions, especially any utility functions for number sanitization or validation logic you add. Use mocking libraries (like jest.mock) to mock the Vonage SDK calls and test your route handlers' logic without making actual API calls.
  • Integration Tests: Test the interaction between your Express routes and the (mocked) Vonage API. Ensure the correct data is passed between routes (e.g., requestId, phoneNumber) and that responses are handled correctly. Tools like supertest can be used to make HTTP requests to your running Express app during tests.
  • End-to-End (E2E) Tests: Use tools like Cypress or Playwright to automate browser interactions. Test the full user flow: entering a phone number, receiving a real SMS (requires a test Vonage account and phone number), entering the code, and verifying success/failure/cancellation. E2E tests are crucial but slower and require managing real credentials/costs.
  • Manual Testing: Thoroughly test the flow manually with different scenarios:
    • Valid phone number, valid code.
    • Valid phone number, invalid code.
    • Valid phone number, expired code (wait > 5 mins).
    • Invalid phone number format.
    • Attempting to check code without requesting first.
    • Cancelling a request.
    • Hitting rate limits (if implemented).
    • Using international numbers.

10. Deployment Considerations

  • Environment Variables: Use the hosting provider's mechanism for setting environment variables (VONAGE_API_KEY, VONAGE_API_SECRET, VONAGE_BRAND_NAME, PORT, NODE_ENV=production). Do not commit .env files or hardcode secrets.
  • Process Management: Use a process manager like PM2 or rely on the platform's built-in service management (e.g., systemd, Heroku dynos, Docker container orchestration) to keep the Node.js application running, manage logs, and handle restarts.
  • HTTPS: Ensure your application is served over HTTPS in production to protect data in transit. Use a reverse proxy like Nginx or Caddy, or leverage platform features (like Heroku Automated Certificate Management, AWS Load Balancer listeners).
  • Logging: Configure production-level logging (e.g., Winston, Pino) to output structured logs to files or a logging service for monitoring and debugging.
  • Dependencies: Run npm install --production (or equivalent) to install only necessary production dependencies.
  • Build Step (if applicable): If using TypeScript or a frontend build process, ensure the build is run before deployment.
  • Database (if applicable): If linking to a user database, ensure connection strings and credentials are secure and configured via environment variables. Set up database migrations.
  • Rate Limiting Storage: For distributed deployments (multiple server instances), configure express-rate-limit (or similar) to use a shared store (like Redis or Memcached) so limits are applied across all instances.

Conclusion

By following this guide, you have implemented a secure SMS-based OTP/2FA verification flow in a Node.js Express application using the Vonage Verify API. This leverages Vonage's infrastructure for reliable code generation, delivery (with fallbacks), expiry management, and verification, simplifying your backend development. Remember to prioritize security through input validation, rate limiting, and secure credential management, especially when deploying to production.

Frequently Asked Questions

How to implement OTP SMS verification in Node.js?

Implement OTP SMS verification using Express, the Vonage Verify API, and the Vonage Server SDK. This setup allows you to collect a user's phone number, send a verification code via SMS, and verify the code entered by the user upon its return, leveraging Vonage's robust API for OTP management.

What is the Vonage Verify API used for?

The Vonage Verify API simplifies the complexities of OTP/2FA by handling OTP generation, delivery with retries, code expiry, and verification checks. It's a core component for securely verifying user identity during sign-ups, logins, or sensitive transactions.

Why does Vonage Verify API simplify 2FA implementation?

Vonage Verify API handles the entire OTP lifecycle, from code generation and SMS/voice delivery to verification and expiry. This offloads the burden of managing these complex processes from the developer, allowing for a simpler and more secure implementation.

When should I use the Vonage Verify API for OTP?

Use the Vonage Verify API whenever you need to verify a user's phone number for security purposes, such as during user registration, login, or when authorizing sensitive transactions. It provides a robust and reliable solution for two-factor authentication.

How to install Vonage Server SDK for Node.js?

Install the Vonage Server SDK using npm or yarn with the command 'npm install @vonage/server-sdk'. This SDK provides the necessary functions to interact with the Vonage Verify API and other Vonage services within your Node.js application.

What is the purpose of the .env file in this project?

The .env file stores sensitive credentials like your Vonage API Key and Secret, keeping them separate from your codebase. It's crucial for security best practices. The 'dotenv' module loads these variables into 'process.env'.

How to set up Vonage API key and secret?

Obtain your Vonage API Key and Secret from the Vonage API Dashboard after signing up for an account. Create a .env file in your project's root directory and add the keys as VONAGE_API_KEY and VONAGE_API_SECRET. Make sure to add .env to your .gitignore file.

How to handle errors with the Vonage Verify API?

Use try...catch blocks around Vonage API calls and check the 'status' property of the response. A '0' status indicates success, while other statuses signal errors detailed in the 'error_text' property. Provide user-friendly feedback without exposing raw API error details.

What are the Vonage Verify API costs?

The article doesn't mention any specific Vonage API costs. You would need to consult the official Vonage pricing documentation to get details of costs involved.

How to test Vonage Verify API integration?

Testing involves unit tests for individual functions, integration tests for interactions between routes and the (mocked) API, and end-to-end tests for the entire user flow. Manual testing with various scenarios, including valid and invalid inputs and edge cases, is also recommended.

Why use express-validator with Vonage Verify API?

Express-validator provides input validation and sanitization to ensure data integrity and security before sending it to the Vonage API. This helps prevent issues such as invalid phone number formats or malicious code injections.

What is the role of body-parser in Vonage OTP project?

Body-parser is middleware that parses incoming request bodies (like form submissions) in JSON or URL-encoded format. It makes the submitted data accessible in req.body within your Express routes, enabling you to process user inputs.

How to handle SMS delivery failures with Vonage?

The Vonage Verify API automatically handles SMS delivery retries and potential fallbacks to voice calls, minimizing the need for custom retry logic. Inform users about potential delays and consider a "Resend Code" option with appropriate rate limiting.

How to resend verification code with Vonage?

Implement a "Resend Code" functionality by triggering the /request-verification route again with the same phone number, allowing users to request a new code if the original wasn't received. Be sure to add rate limiting to this endpoint to prevent abuse.