code examples

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

Twilio Verify API with Node.js and Fastify: Implement OTP 2FA in 15 Minutes

Learn how to implement two-factor authentication using Twilio Verify API, Node.js, and Fastify. Complete tutorial with SMS OTP verification, phone number validation, error handling, and production-ready security best practices.

Learn how to implement secure two-factor authentication (2FA) using Twilio Verify API with Node.js and Fastify. This step-by-step tutorial shows you how to send SMS verification codes, validate phone numbers, and verify OTP codes to add a critical security layer to your application.

Build a web application with two core functions: request an OTP sent via SMS to a user's phone number, then verify the OTP they enter. This ensures users possess the registered phone number during login or sensitive operations.

What You'll Build: Twilio OTP Authentication System

What We're Building:

A Node.js web application using the Fastify framework that integrates with the Twilio Verify API to:

  1. Accept a user's phone number via an HTML form.
  2. Request Twilio to send an OTP (PIN code) to that number via SMS.
  3. Present a form for the user to enter the received OTP.
  4. Verify the submitted OTP against the Twilio request.
  5. Display a success or failure message.

Problem Solved:

This implementation addresses the need for stronger user authentication beyond simple passwords, mitigating risks associated with compromised credentials. It provides a practical example of adding SMS-based OTP verification to any Node.js application.

Technologies Used:

  • Node.js: The JavaScript runtime environment (supports versions 14, 16, 18, 20, and LTS 22 as of 2025).1
  • Fastify: A high-performance, low-overhead Node.js web framework (v5.6.x as of 2025). Chosen for its speed, extensibility, and developer experience.2
  • Twilio Verify API: A service for sending and checking verification codes via SMS, voice, email, WhatsApp, and other channels. Simplifies the complex logic of OTP delivery and management.3
  • twilio: The official Twilio Node.js SDK for interacting with the API.1
  • @fastify/view & ejs: For server-side rendering of simple HTML templates.
  • @fastify/formbody: To parse application/x-www-form-urlencoded request bodies (standard HTML form submissions).
  • dotenv: To manage environment variables securely.
  • libphonenumber-js: For robust phone number validation and formatting.

System Architecture:

mermaid
graph LR
    A[User Browser] -- 1. Enters Phone Number --> B(Fastify App);
    B -- 2. Sends Verify Request (Number) --> C(Twilio Verify API);
    C -- 3. Sends OTP via SMS --> D(User's Phone);
    D -- 4. User Enters OTP --> A;
    A -- 5. Submits OTP & Verification SID --> B;
    B -- 6. Sends Check Request (Code, SID) --> C;
    C -- 7. Returns Verification Status --> B;
    B -- 8. Displays Success/Failure --> A;

Prerequisites:

  • Install Node.js (LTS version 20 or 22 recommended as of 2025) and npm (or yarn).
  • Sign up at https://www.twilio.com/try-twilio if you don't have a Twilio account with an active Verify Service.
  • Locate your Twilio Account SID and Auth Token on your Twilio Console dashboard.
  • Create a Twilio Verify Service SID in the Twilio Console under Verify > Services.
  • Install a text editor or IDE (e.g., VS Code).
  • Access a terminal or command prompt.

Security Considerations (2025 NIST Guidelines):

NIST SP 800-63B classifies SMS-based authentication as "RESTRICTED" due to vulnerabilities including SIM swapping and number porting attacks.4 Organizations using SMS for 2FA should:

  • Offer alternative non-SMS authenticators (TOTP, hardware tokens, passkeys)
  • Monitor for risk indicators (device swap, SIM change, number porting)
  • Provide users with meaningful notice about security limitations
  • Develop migration plans toward more secure authentication methods

Expected Outcome:

A functional web application running locally that demonstrates the complete Twilio OTP request and verification flow using Fastify.

1. Setting up Your Node.js Project with Twilio

Initialize your Node.js project and install the necessary dependencies.

  1. Create Project Directory:

    Open your terminal, create a new directory for the project, then navigate into it.

    bash
    mkdir fastify-twilio-otp
    cd fastify-twilio-otp
  2. Initialize Node.js Project:

    Create a package.json file to manage dependencies and project metadata.

    bash
    npm init -y
  3. Install Dependencies:

    Install Fastify, the Twilio SDK, template rendering, form body parsing, environment variable management, and phone number validation.

    bash
    npm install fastify twilio @fastify/view ejs @fastify/formbody dotenv libphonenumber-js
  4. Install Development Dependency (Optional but Recommended):

    Install nodemon to automatically restart the server during development when file changes are detected.

    bash
    npm install --save-dev nodemon
  5. Create Project Structure:

    Set up a basic structure for configuration, server logic, and views.

    bash
    mkdir src
    mkdir views
    touch src/server.js
    touch views/request-form.ejs
    touch views/verify-form.ejs
    touch views/success.ejs
    touch views/error.ejs
    touch .env
    touch .env.example
    touch .gitignore
    • src/server.js: Main application logic.
    • views/: Directory for HTML templates.
    • .env: Stores sensitive credentials (API keys). Never commit this file.
    • .env.example: Example structure for .env (safe to commit).
    • .gitignore: Specifies files/directories Git should ignore.
  6. Configure .gitignore:

    Add node_modules and .env to prevent committing them.

    ini
    # .gitignore
    
    node_modules
    .env
    *.log
  7. Configure Environment Variables:

    Add placeholders to .env.example and add your actual credentials to .env.

    ini
    # .env.example
    
    TWILIO_ACCOUNT_SID=YOUR_ACCOUNT_SID
    TWILIO_AUTH_TOKEN=YOUR_AUTH_TOKEN
    TWILIO_VERIFY_SERVICE_SID=YOUR_VERIFY_SERVICE_SID
    TWILIO_BRAND_NAME="Awesome App" # Optional: Brand name shown in SMS message
    PORT=3000

    Now open .env and replace the placeholders with your actual Twilio credentials from the Twilio Console. Customize TWILIO_BRAND_NAME if desired.

    ini
    # .env - DO NOT COMMIT THIS FILE
    
    TWILIO_ACCOUNT_SID=xxxxxxxx
    TWILIO_AUTH_TOKEN=yyyyyyyyyyyyyyyy
    TWILIO_VERIFY_SERVICE_SID=zzzzzzzz
    TWILIO_BRAND_NAME="My Secure App"
    PORT=3000
  8. Add package.json Scripts:

    Open package.json and add scripts for starting the server normally and with nodemon.

    json
    {
      "name": "fastify-twilio-otp",
      "version": "1.0.0",
      "description": "",
      "main": "src/server.js",
      "scripts": {
        "start": "node src/server.js",
        "dev": "nodemon src/server.js",
        "test": "echo \"Error: no test specified\" && exit 1"
      },
      "keywords": [],
      "author": "",
      "license": "ISC",
      "dependencies": {
        "@fastify/formbody": "^8.0.0",
        "@fastify/view": "^10.0.0",
        "twilio": "^5.3.0",
        "dotenv": "^16.4.5",
        "ejs": "^3.1.10",
        "fastify": "^5.6.0",
        "libphonenumber-js": "^1.11.14"
      },
      "devDependencies": {
        "nodemon": "^3.1.9"
      }
    }

    (Note: These versions are current as of 2025. Fastify v5.6.x includes performance improvements and better TypeScript support. Ensure the versions in your package.json reflect what npm install added, as newer versions may be available.)5

Now the basic project structure and dependencies are set up.

2. Implementing the Twilio Verify API Integration

Build the Fastify server and integrate the Twilio Verify logic.

  1. Basic Server Setup (src/server.js):

    Initialize Fastify, load environment variables, register necessary plugins, and import the phone number library.

    javascript
    // src/server.js
    'use strict';
    
    const path = require('node:path');
    const Fastify = require('fastify');
    const fastifyView = require('@fastify/view');
    const fastifyFormbody = require('@fastify/formbody');
    const ejs = require('ejs');
    const { parsePhoneNumberFromString } = require('libphonenumber-js'); // Import phone number library
    
    // Load environment variables
    require('dotenv').config();
    
    // 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, and TWILIO_VERIFY_SERVICE_SID must be set in .env file');
        process.exit(1);
    }
    
    const PORT = process.env.PORT || 3000;
    const TWILIO_BRAND_NAME = process.env.TWILIO_BRAND_NAME || 'MyApp'; // Default brand
    
    // Initialize Fastify
    const fastify = Fastify({
        logger: true // Enable basic logging
    });
    
    // Register Fastify plugins
    fastify.register(fastifyFormbody); // For parsing form data
    fastify.register(fastifyView, {
        engine: {
            ejs: ejs,
        },
        root: path.join(__dirname, '../views'), // Path to templates
        viewExt: 'ejs', // Default template extension
    });
    
    // Initialize Twilio SDK
    const twilio = require('twilio')(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
    
    // --- Routes will be added here ---
    
    // Start the server
    const start = async () => {
        try {
            await fastify.listen({ port: PORT });
            fastify.log.info(`Server listening on port ${PORT}`);
        } catch (err) {
            fastify.log.error(err);
            process.exit(1);
        }
    };
    
    start();
    • Import necessary modules, including parsePhoneNumberFromString.
    • dotenv.config() loads variables from .env.
    • Validate that essential Twilio keys are present.
    • Initialize Fastify with logging enabled.
    • Register @fastify/formbody to parse POST request bodies from HTML forms.
    • Register @fastify/view with ejs as the templating engine, pointing to the views directory.
    • Instantiate the Twilio SDK using credentials from environment variables.
    • Define and call a basic server start function with error handling.
  2. Create HTML Templates:

    Populate the .ejs files with simple HTML forms.

    • views/request-form.ejs: Form to enter the phone number.

      html
      <!DOCTYPE html>
      <html lang="en">
      <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <title>Request OTP</title>
          <style>body { font-family: sans-serif; padding: 20px; } label, input, button { display: block; margin-bottom: 10px; } .error { color: red; margin-top: 15px; }</style>
      </head>
      <body>
          <h1>Enter Your Phone Number</h1>
          <p>We'll send an OTP code via SMS. Use international format (e.g., +14155551212).</p>
      
          <form method="POST" action="/request-otp">
              <label for="number">Phone Number (e.g., +14155551212):</label>
              <input type="tel" id="number" name="number" required placeholder="+14155551212">
              <button type="submit">Send OTP</button>
          </form>
      
          <% if (typeof error !== 'undefined' && error) { %>
              <p class="error">Error: <%= error %></p>
          <% } %>
      </body>
      </html>
    • views/verify-form.ejs: Form to enter the received OTP. It includes a hidden field for the requestId.

      html
      <!DOCTYPE html>
      <html lang="en">
      <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <title>Verify OTP</title>
          <style>body { font-family: sans-serif; padding: 20px; } label, input, button { display: block; margin-bottom: 10px; } .error { color: red; margin-top: 15px; }</style>
      </head>
      <body>
          <h1>Enter OTP Code</h1>
          <p>Check your phone for the code.</p>
      
          <form method="POST" action="/verify-otp">
              <label for="code">OTP Code:</label>
              <input type="text" id="code" name="code" required pattern="\d{4,6}" title="Enter the 4-6 digit code">
      
              <input type="hidden" name="requestId" value="<%= requestId %>">
      
              <button type="submit">Verify Code</button>
          </form>
      
           <% if (typeof error !== 'undefined' && error) { %>
              <p class="error">Error: <%= error %></p>
          <% } %>
      </body>
      </html>
    • views/success.ejs: Success message page.

      html
      <!DOCTYPE html>
      <html lang="en">
      <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <title>Success</title>
          <style>body { font-family: sans-serif; padding: 20px; } .success { color: green; }</style>
      </head>
      <body>
          <h1 class="success">Verification Successful!</h1>
          <p>Your phone number has been verified.</p>
          <a href="/">Start Over</a>
      </body>
      </html>
    • views/error.ejs: Generic error message page.

      html
      <!DOCTYPE html>
      <html lang="en">
      <head>
          <meta charset="UTF-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0">
          <title>Error</title>
           <style>body { font-family: sans-serif; padding: 20px; } .error { color: red; }</style>
      </head>
      <body>
          <h1 class="error">Verification Failed</h1>
          <p>An error occurred: <%= message %></p>
           <p><a href="/">Try Again</a></p>
      </body>
      </html>
  3. Implement Routes (src/server.js):

    Add the Fastify routes to handle the OTP flow. Place this code before the start() function call in src/server.js.

    javascript
    // src/server.js (add this section)
    
    // --- Routes ---
    
    // Route to display the initial phone number form
    fastify.get('/', (request, reply) => {
        reply.view('request-form', { error: null }); // Pass null error initially
    });
    
    // Route to handle the phone number submission and request OTP from Twilio
    fastify.post('/request-otp', async (request, reply) => {
        const { number } = request.body;
    
        // Validate phone number using libphonenumber-js
        const phoneNumber = parsePhoneNumberFromString(number); // No default country needed if expecting international format
    
        if (!phoneNumber || !phoneNumber.isValid()) {
            fastify.log.warn(`Invalid phone number format received: ${number}`);
            return reply.view('request-form', { error: 'Invalid phone number format. Please use international format (e.g., +14155551212).' });
        }
    
        // Use the E.164 format for the Twilio API call
        const numberE164 = phoneNumber.format('E.164'); // e.g., +14155551212
        // Twilio Verify API often expects the number *without* the leading '+'
        const numberForTwilio = numberE164.startsWith('+') ? numberE164.substring(1) : numberE164;
    
        fastify.log.info(`Requesting OTP for validated number (E.164): ${numberE164}`);
    
        try {
            const result = await twilio.verify.v2.services(process.env.TWILIO_VERIFY_SERVICE_SID).verifications.create({
                to: numberForTwilio, // Send number without '+'
                channel: 'sms',
                brand: TWILIO_BRAND_NAME,
                code_length: '6' // Optional: Specify 4 or 6 digits (default is 4)
                // workflow_id: 1 // Optional: Specify workflow (SMS -> TTS -> TTS is default)
            });
    
            fastify.log.info(`Twilio Verify request sent, Verification SID: ${result.sid}`);
    
            // Check the Twilio API response status explicitly
            // Status 'pending' means Twilio accepted the request. Non-pending indicates an issue.
            if (result.status === 'pending') {
                 // Render the verification form, passing the verification_sid
                return reply.view('verify-form', { requestId: result.sid, error: null });
            } else {
                // If Twilio returns an error status initially
                fastify.log.error(`Twilio Verify start error for number ${numberForTwilio}: ${result.error_message} (Status: ${result.status})`);
                // Check for specific errors like invalid number format reported by Twilio itself
                let userErrorMessage = `Could not initiate verification: ${result.error_message || 'Unknown error from verification service.'}`;
                if (result.status === '3') { // '3' often means Invalid number
                    userErrorMessage = 'The phone number format was rejected by the verification service. Please check the number and format.';
                }
                 return reply.view('request-form', { error: userErrorMessage });
            }
    
        } catch (error) {
            fastify.log.error(`Twilio SDK error during verify start: ${error.message}`);
            // Handle potential SDK errors (network issues, config errors etc.)
            return reply.view('request-form', { error: 'An unexpected error occurred while contacting the verification service. Please try again later.' });
        }
    });
    
    // Route to handle the OTP submission and verify it with Twilio
    fastify.post('/verify-otp', async (request, reply) => {
        const { code, requestId } = request.body;
    
        // Basic validation for code and requestId presence
        if (!code || !requestId || !/^\d{4,6}$/.test(code)) {
             fastify.log.warn(`Invalid code format or missing requestId. Code: ${code}, RequestID: ${requestId}`);
             // Attempt to render verify form again if requestId is available, else show generic error
             if (requestId) {
                return reply.view('verify-form', { requestId: requestId, error: 'Invalid or missing code. Please enter the 4-6 digit code received.' });
             } else {
                 // If requestId is missing, we can't really proceed with the verify form
                 return reply.code(400).view('error', { message: 'Missing verification session information. Please start over.' });
             }
        }
    
    
        fastify.log.info(`Verifying OTP for Verification SID: ${requestId}`);
    
        try {
            const result = await twilio.verify.v2.services(process.env.TWILIO_VERIFY_SERVICE_SID).verificationChecks.create({
                verificationSid: requestId,
                code: code
            });
    
            fastify.log.info(`Twilio Verify check result: Status ${result.status}`);
    
            // Status 'approved' means successful verification
            if (result.status === 'approved') {
                fastify.log.info(`Verification successful for Verification SID: ${requestId}`);
                // In a real app, you would now associate the verified number with the user account,
                // set a session flag, issue a JWT, etc.
                return reply.view('success');
            } else {
                // Handle specific Twilio error statuses
                fastify.log.warn(`Verification failed for Verification SID: ${requestId}. Status: ${result.status}, Error: ${result.error_message}`);
                let errorMessage = `Verification failed: ${result.error_message || 'Invalid code or request expired.'}`;
                // Provide more user-friendly messages based on status codes if needed
                 if (result.status === '6') { // Invalid Code
                     errorMessage = 'The code you entered was incorrect. Please try again.';
                 } else if (result.status === '16') { // Request Not Found / Expired
                     errorMessage = 'The verification request has expired or is invalid. Please request a new code.';
                 } else if (result.status === '17') { // Too Many Attempts
                     errorMessage = 'Too many incorrect attempts. Please request a new code.';
                 }
                // See: https://www.twilio.com/docs/verify/api/verification-checks#status
                return reply.view('verify-form', { requestId: requestId, error: errorMessage });
            }
    
        } catch (error) {
             fastify.log.error(`Twilio SDK error during verify check: ${error.message}`);
              // Handle potential SDK errors
            return reply.view('verify-form', { requestId: requestId, error: 'An unexpected error occurred while checking the code. Please try again later.' });
        }
    });
    
    // Generic Error Handler (Optional but recommended)
    fastify.setErrorHandler(function (error, request, reply) {
      fastify.log.error(`Unhandled error: ${error.message}\n${error.stack}`);
      // Send generic error response
       if (!reply.sent) { // Check if a response hasn't already been sent
          reply.code(500).view('error', { message: 'An internal server error occurred.' });
       }
    });
    
    // --- End of Routes ---
    • GET /: Renders the initial request-form.ejs.
    • POST /request-otp:
      • Retrieves the number from the form body.
      • Uses libphonenumber-js to parse and validate the number.
      • If invalid, shows an error on the request-form.
      • Formats the valid number to E.164 and removes the leading + as often expected by Twilio Verify.
      • Calls twilio.verify.v2.services(process.env.TWILIO_VERIFY_SERVICE_SID).verifications.create() with the validated number and brand name. Explicitly requests a 6-digit code (code_length: '6').
      • Checks result.status. If pending, renders verify-form.ejs, passing the result.sid. If non-pending, shows an error on the initial form, potentially more specific if the status code is known (like '3' for invalid number).
      • Includes try...catch for SDK/network errors.
    • POST /verify-otp:
      • Retrieves the code and requestId from the form body. Performs basic format validation on the code.
      • Calls twilio.verify.v2.services(process.env.TWILIO_VERIFY_SERVICE_SID).verificationChecks.create() with the requestId and code.
      • Checks result.status. If approved, renders success.ejs. If non-approved, renders verify-form.ejs again with a user-friendly error message derived from result.error_message and common status codes.
      • Includes try...catch for SDK/network errors.
    • setErrorHandler: A basic global error handler catches unhandled exceptions and displays a generic error page.
  4. Run the Application:

    bash
    npm run dev

    Open your browser and navigate to http://localhost:3000 (or the port you configured). You should see the form to enter your phone number.

3. Building a RESTful API for OTP Verification

While this guide focuses on server-rendered HTML, the core Twilio logic can easily be exposed via a JSON API.

Example API Endpoints:

  • POST /api/otp/request
    • Request Body (JSON): { "phoneNumber": "+14155551212" }
    • Response (Success - 200 OK): { "requestId": "a1b2c3d4e5f6…" }
    • Response (Error - 400/500): { "error": "Invalid phone number format" } or { "error": "Failed to initiate verification" }
  • POST /api/otp/verify
    • Request Body (JSON): { "requestId": "a1b2c3d4e5f6…", "code": "123456" }
    • Response (Success - 200 OK): { "status": "verified" }
    • Response (Error - 400 Bad Request): { "error": "Invalid code or request expired." }
    • Response (Error - 500): { "error": "Verification check failed" }

Implementation Sketch (in src/server.js):

javascript
// Example API route for requesting OTP
fastify.post('/api/otp/request', async (request, reply) => {
    const { phoneNumber: rawPhoneNumber } = request.body;

    // Use libphonenumber-js for validation
    const phoneNumber = parsePhoneNumberFromString(rawPhoneNumber);
    if (!phoneNumber || !phoneNumber.isValid()) {
        return reply.code(400).send({ error: 'Invalid phoneNumber format. Use E.164 format (e.g., +14155551212).' });
    }
    const numberForTwilio = phoneNumber.format('E.164').substring(1); // Remove leading '+'

    try {
        const result = await twilio.verify.v2.services(process.env.TWILIO_VERIFY_SERVICE_SID).verifications.create({ number: numberForTwilio, brand: TWILIO_BRAND_NAME, code_length: '6' });
        if (result.status === 'pending') {
            reply.send({ requestId: result.sid });
        } else {
             fastify.log.error(`API Twilio Verify start error: ${result.error_message} (Status: ${result.status})`);
            reply.code(400).send({ error: `Could not initiate verification: ${result.error_message || 'Unknown error'}` });
        }
    } catch (error) {
        fastify.log.error(`API Twilio SDK error during verify start: ${error.message}`);
        reply.code(500).send({ error: 'Verification service error' });
    }
});

// Example API route for verifying OTP
fastify.post('/api/otp/verify', async (request, reply) => {
    const { requestId, code } = request.body;

     // Add input validation (e.g., using JSON Schema for routes is better)
    if (!requestId || !code || !/^\d{4,6}$/.test(code)) {
         return reply.code(400).send({ error: 'Missing or invalid requestId or code (must be 4-6 digits)' });
    }
    try {
        const result = await twilio.verify.v2.services(process.env.TWILIO_VERIFY_SERVICE_SID).verificationChecks.create({ verificationSid: requestId, code: code });
        if (result.status === 'approved') {
            // In a real API, likely issue a JWT or session token here
            reply.send({ status: 'verified' });
        } else {
             fastify.log.warn(`API Verification failed for Verification SID: ${requestId}. Status: ${result.status}, Error: ${result.error_message}`);
             reply.code(400).send({ error: `Verification failed: ${result.error_message || 'Invalid code or request expired.'}` });
        }
    } catch (error) {
         fastify.log.error(`API Twilio SDK error during verify check: ${error.message}`);
         reply.code(500).send({ error: 'Verification check failed' });
    }
});

Testing API Endpoints (using curl):

bash
# Request OTP (Use E.164 format with +)
curl -X POST http://localhost:3000/api/otp/request \
     -H "Content-Type: application/json" \
     -d '{"phoneNumber": "+1YOUR_PHONE_NUMBER_E164"}'

# Verify OTP (replace with actual requestId and code)
curl -X POST http://localhost:3000/api/otp/verify \
     -H "Content-Type: application/json" \
     -d '{"requestId": "YOUR_REQUEST_ID", "code": "YOUR_RECEIVED_CODE"}'

4. Configuring Twilio Verify Service Credentials

  • API Credentials:

    • TWILIO_ACCOUNT_SID: Your public API key from the Twilio Console.
    • TWILIO_AUTH_TOKEN: Your private API secret from the Twilio Console. Treat this like a password.
    • How to Obtain:
      1. Log in to your Twilio Console.
      2. The Console displays your API Key and Secret prominently on the main dashboard page under "Account Info".
      3. Copy these values directly into your .env file.
  • Environment Variables:

    • TWILIO_ACCOUNT_SID: (String) Required for authentication. Format: Typically 34 alphanumeric characters.
    • TWILIO_AUTH_TOKEN: (String) Required for authentication. Format: Typically 32 alphanumeric characters.
    • TWILIO_VERIFY_SERVICE_SID: (String) Required for specifying the Verify Service. Format: Typically 34 alphanumeric characters.
    • TWILIO_BRAND_NAME: (String, Optional) The name displayed in the SMS message (e.g., "Your code from [Brand Name] is…"). Max 11 alphanumeric characters or 16 digits for numeric sender ID. Defaults to 'MyApp' in our code if not set.
    • PORT: (Number, Optional) The port the Fastify server listens on. Defaults to 3000.
  • Secure Storage: Using .env and dotenv keeps credentials out of your source code. Ensure .env is listed in your .gitignore file. In production environments (like Heroku, AWS, etc.), use the platform's mechanism for setting environment variables securely – do not deploy .env files.

  • Fallback Mechanisms: The Twilio Verify API itself handles retries and channel fallbacks (e.g., SMS -> Text-to-Speech call) based on the chosen workflow_id (default is workflow 1). You generally don't need to implement complex fallback logic on the client-side for delivery itself, but robust application-level error handling (as shown in the route examples) is essential.

5. Error Handling and Logging Best Practices

  • Error Handling Strategy:

    • Specific Twilio Errors: Check the status and error_message fields in the Twilio API responses (verify.create and verificationChecks.create). Provide user-friendly messages based on common statuses (e.g., invalid code, expired request). Link to Twilio Verify API Errors. Our code examples show basic mapping for common errors.
    • SDK/Network Errors: Use try...catch blocks around Twilio SDK calls to handle network issues, timeouts, or configuration errors. Log these errors server-side and provide generic user messages.
    • Validation Errors: Handle invalid user input (e.g., bad phone number format, missing/invalid code) before calling the Twilio API using libphonenumber-js and basic checks.
    • Global Handler: Use Fastify's setErrorHandler for unexpected/unhandled errors to prevent crashes and provide a generic error page/response.
  • Logging:

    • Fastify's built-in logger (fastify.log) provides basic request logging and methods like info, warn, error.
    • Log key events: OTP request initiation (with validated number), Twilio response status (success/failure with status code and error message), verification attempt (with request ID), verification result.
    • Log validation failures (e.g., invalid phone number attempts).
    • In production, consider using a more robust logger (like Pino, which Fastify uses internally, or Winston) with structured logging (JSON format) and configurable log levels (e.g., INFO for production, DEBUG for development). Send logs to a centralized logging service (e.g., Datadog, Logstash, CloudWatch).
  • Retry Mechanisms (Application Level):

    • User Retries: Allow the user to request a new code if they didn't receive the first one (potentially after a delay). This is implicitly handled by allowing them back to the first form (/). Consider adding a "Resend Code" button on the verify-form that navigates back or triggers /request-otp again (with rate limiting).
    • API Call Retries: Retrying failed verify.create or verificationChecks.create calls immediately is usually not recommended, as the issue might be persistent (e.g., invalid API key, Twilio outage, invalid number). Log the error and inform the user. If temporary network issues are suspected, a cautious retry with exponential backoff could be considered for specific error types, but often informing the user to try again later is safer. Twilio handles SMS delivery retries internally.

6. Database Integration for User Authentication

While this guide doesn't implement a database, in a real-world application, you would integrate this OTP flow with your user management system.

  • Schema: You'd typically have a users table. You might add fields like:

    • phone_number (VARCHAR, UNIQUE) - Store in E.164 format.
    • phone_verified_at (TIMESTAMP, NULLABLE)
    • two_factor_enabled (BOOLEAN, DEFAULT FALSE)
    • (Optionally) Store the last_verify_request_id temporarily if needed for specific flows, but Twilio manages the core state.
  • Data Layer:

    • When a user registers or adds a phone number, validate it using libphonenumber-js, store the E.164 format in the users table.
    • Upon successful OTP verification (/verify-otp success), update the corresponding user record: set phone_verified_at to the current time and potentially two_factor_enabled to true.
    • Use an ORM (like Prisma, TypeORM, Sequelize) or a query builder (like Knex.js) to interact with the database safely.

7. Production Security Features

  • Input Validation:

    • Phone Numbers: Use libphonenumber-js for robust international phone number validation and formatting (as implemented in Section 2). Always validate before sending to Twilio.
    • OTP Codes: Validate that the code format matches expectations (e.g., 4-6 digits, as shown in /verify-otp route).
    • Request IDs: Ensure they are present and seem well-formed (though validating their actual existence relies on the Twilio check).
    • Use Fastify's built-in JSON Schema validation for API routes (/api/*) for stronger type and format enforcement.
  • Rate Limiting: Crucial to prevent abuse (SMS spamming/toll fraud) and brute-force attacks.

    • Use @fastify/rate-limit. Apply limits to both /request-otp (prevent spamming SMS) and /verify-otp (prevent brute-forcing codes). Also apply to API equivalents.
    • Configure reasonable limits (e.g., 5 requests per phone number per hour, 10 verification attempts per request ID per 5 minutes).
    javascript
    // Example Rate Limiting Setup (in src/server.js)
    const fastifyRateLimit = require('@fastify/rate-limit');
    
    async function setupRateLimiting(fastifyInstance) {
        await fastifyInstance.register(fastifyRateLimit, {
            max: 100, // Default max requests per windowMs
            timeWindow: '1 minute',
            // Example specific limits (apply within route config)
            // keyGenerator: function (request) { /* ... */ }, // Key by IP, phone number, etc.
            // allowList: [],
            // etc.
        });
        // Apply specific limits in route options, e.g.:
        // fastify.post('/request-otp', { config: { rateLimit: { max: 5, timeWindow: '1 hour', keyGenerator: ... } } }, async (req, reply) => { ... });
        // fastify.post('/verify-otp', { config: { rateLimit: { max: 10, timeWindow: '5 minutes', keyGenerator: ... } } }, async (req, reply) => { ... });
    }
    
    // Call setupRateLimiting(fastify) after initializing fastify
    // Note: Detailed implementation requires careful key generation (e.g., based on phone number for /request-otp,
    // or requestId/IP for /verify-otp) and applying limits within route definitions.

Frequently Asked Questions About Twilio OTP Implementation

How do I get started with Twilio Verify API for OTP?

Sign up for a Twilio account at https://www.twilio.com/try-twilio, obtain your Account SID and Auth Token from the Console dashboard, then create a Verify Service under Verify > Services. Use these credentials in your .env file to authenticate API requests.

What is the difference between Twilio Verify API and SMS API?

Twilio Verify API is purpose-built for OTP and 2FA workflows, handling code generation, delivery retries, rate limiting, and expiration automatically. The SMS API requires you to manually implement these features. Verify API supports multiple channels (SMS, voice, email, WhatsApp) with automatic fallback.

Why does NIST classify SMS authentication as "RESTRICTED"?

NIST SP 800-63B classifies SMS as RESTRICTED due to vulnerabilities including SIM swapping attacks, number porting exploits, and SS7 protocol weaknesses.4 Organizations should offer alternative authenticators like TOTP apps, hardware tokens, or passkeys, and monitor for risk indicators like device swaps.

How do I validate international phone numbers in Node.js?

Use the libphonenumber-js library to parse and validate phone numbers. Call parsePhoneNumberFromString(number) and check isValid(). Format validated numbers to E.164 standard using format('E.164') before sending to Twilio Verify API. This ensures consistent formatting across all countries.

What is the default OTP code length in Twilio Verify?

Twilio Verify API defaults to 4-digit verification codes. Specify code_length: '6' in the verifications.create() call to use 6-digit codes for enhanced security. Longer codes provide better protection against brute-force attacks while remaining user-friendly.

How do I implement rate limiting for OTP endpoints in Fastify?

Install @fastify/rate-limit and register it with your Fastify instance. Apply route-specific limits using the config.rateLimit option: limit /request-otp to 5 requests per phone number per hour to prevent SMS abuse, and limit /verify-otp to 10 attempts per verification ID per 5 minutes to prevent brute-forcing.

Can I use Twilio Verify API with Fastify v5?

Yes, Twilio Verify API works with Fastify v5.6.x (current as of 2025). The twilio Node.js SDK (v5.3.0+) supports Node.js 14, 16, 18, 20, and LTS 22. Fastify v5 includes performance improvements and better TypeScript support without breaking changes to plugin APIs used in this guide.

What happens if the user doesn't receive the OTP code?

Twilio Verify API automatically handles SMS delivery retries. If delivery fails, the API can fall back to voice calls based on your workflow configuration. Implement a "Resend Code" button that calls /request-otp again with rate limiting. Check Twilio Console logs for delivery status and carrier-specific issues.

Footnotes

  1. https://github.com/twilio/twilio-node 2

  2. https://fastify.dev

  3. https://www.twilio.com/docs/verify/api

  4. https://pages.nist.gov/800-63-3/sp800-63b.html 2

  5. https://fastify.dev

Frequently Asked Questions

How to implement two-factor authentication with Node.js?

Implement 2FA using the Vonage Verify API with Node.js and the Fastify framework. This involves accepting a user's phone number, sending an OTP via SMS using the Vonage API, and then verifying the user-entered OTP against the Vonage request to enhance login security.

What is the Vonage Verify API used for in Node.js?

The Vonage Verify API simplifies the process of sending and verifying one-time passwords (OTPs) within Node.js applications. It handles the complex logic of OTP delivery and management via SMS, voice, and other channels, enhancing security beyond simple passwords.

Why use Fastify for a Node.js 2FA project?

Fastify is a high-performance Node.js web framework chosen for its speed and developer-friendly experience. Its extensibility makes it ideal for integrating services like the Vonage Verify API, and its low overhead contributes to application efficiency.

When should I add 2FA to my Node.js application?

Two-factor authentication (2FA) should be added to your Node.js application anytime you need to strengthen user authentication beyond relying solely on potentially vulnerable passwords. This is especially important during login and other sensitive actions.

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 allows your Node.js application to interact with the Vonage Verify API for sending and verifying OTPs.

What is libphonenumber-js used for in 2FA?

`libphonenumber-js` provides robust phone number validation and formatting in your 2FA implementation. This ensures phone numbers are in the correct international format before sending OTP requests to Vonage.

What are the Vonage API credentials I need for OTP?

You'll need your Vonage API Key and API Secret, both found on your Vonage Dashboard. These credentials are essential for authenticating with the Vonage API and are used when initializing the Vonage SDK within your application.

How to set environment variables in Node.js with dotenv?

Create a `.env` file in your project root and store sensitive information like API keys there. Install `dotenv` with npm, require it in `server.js` with `require('dotenv').config()`, then access via `process.env.VARIABLE_NAME`.

Why shouldn't I commit the .env file?

The `.env` file contains sensitive data like API keys which should never be exposed publicly. Add `.env` to your `.gitignore` file to prevent it from being accidentally committed to version control.

How to handle Vonage Verify API errors in Node.js?

Check the `status` and `error_text` fields in Vonage API responses. Provide user-friendly error messages based on common status codes like invalid numbers, expired requests, or too many attempts. For SDK or network errors, use `try...catch` blocks and log errors server-side.

What is the role of requestId in Vonage Verify API?

The `requestId` is a unique identifier returned by `vonage.verify.start()`. It's crucial for tracking the verification process. It's passed to `vonage.verify.check()` along with the OTP to verify the user's input.

How to validate phone numbers for Vonage Verify API?

Use the `libphonenumber-js` library to validate international phone numbers before sending them to the Vonage Verify API. Parse the input with `parsePhoneNumberFromString` and check validity with `phoneNumber.isValid()`. Use E.164 formatting for consistency.

How to create a JSON API for Vonage 2FA in Fastify?

Create separate API endpoints (e.g., `/api/otp/request`, `/api/otp/verify`) in your Fastify application. Use `request.body` to handle JSON payloads and send responses with `reply.send()` and appropriate status codes (e.g., 200 OK, 400 Bad Request).

Why is rate limiting important for 2FA?

Rate limiting prevents abuse such as SMS spamming and brute-force attacks on OTP codes. Implement rate limiting on your 2FA routes (`/request-otp`, `/verify-otp`) using Fastify plugins or middleware to limit requests per phone number or IP within a timeframe.