code examples

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

Build OTP/2FA with MessageBird Verify API: Complete Vite + React/Vue Guide

Implement secure SMS-based OTP two-factor authentication using MessageBird Verify API with Node.js backend and Vite (React/Vue) frontend. Complete tutorial with code generation, verification, and security best practices.

Build Secure OTP/2FA with MessageBird Verify API: Complete Vite + React/Vue Integration Guide

Build a production-ready SMS-based One-Time Password (OTP) two-factor authentication (2FA) system using MessageBird's Verify API with a Node.js/Express backend and modern Vite-powered frontend (React or Vue). Implement secure code generation, verification flows, and handle edge cases for real-world applications.

What is OTP/2FA and Why MessageBird?

One-Time Password (OTP) authentication sends a temporary numeric code (typically 4–10 digits) to a user's mobile phone via SMS. Users must enter this code within a limited timeframe (usually 30 seconds to 2 days) to verify their identity. Two-Factor Authentication (2FA) adds this second verification layer beyond username/password, significantly reducing unauthorized access even if passwords are compromised (NIST SP 800-63B recommends 2FA for sensitive applications as of 2023).

MessageBird's Verify API simplifies OTP implementation by:

  • Automatically generating secure random tokens (6–10 digits, configurable)
  • Handling message delivery via SMS, voice (TTS), or email
  • Managing token expiration (30 seconds to 2 days)
  • Tracking verification attempts (1–10 configurable limit)
  • Providing status tracking (sent, verified, expired, failed)

According to MessageBird's official Verify API documentation, the service handles token generation server-side, eliminating client-side security risks and simplifying implementation compared to manual OTP solutions.

Project Overview and Goals

Goal: Build a full-stack OTP/2FA system with:

  • Backend (Node.js/Express): Three API endpoints for generating OTP codes (/api/otp/generate), verifying codes (/api/otp/verify), and viewing verification status (/api/otp/status)
  • Frontend (Vite + React or Vue): Phone number input form, OTP code entry interface with auto-focus, and verification status display
  • Security: Rate limiting, attempt tracking, secure token comparison, and proper error handling

Problem Solved: Adds robust two-factor authentication to protect user accounts, secure transactions, verify phone numbers during registration, and meet compliance requirements for financial services, healthcare, or e-commerce applications.

Technologies Used:

  • Node.js: JavaScript runtime for backend server
  • Express: Web application framework for API routing
  • messagebird npm package: Official MessageBird SDK for Node.js (v4.0.1 as of January 2022, Apache-2.0 license, GitHub repository)
  • dotenv: Environment variable management
  • Vite: Next-generation frontend build tool (faster than webpack/CRA)
  • React or Vue 3: Modern frontend framework for UI components
  • axios: HTTP client for API requests from frontend
  • express-rate-limit: Protection against brute-force attacks

System Architecture:

The complete OTP flow involves both frontend and backend coordination:

mermaid
sequenceDiagram
    participant User
    participant Frontend as Vite Frontend<br/>(React/Vue)
    participant Backend as Node.js/Express API
    participant MessageBird as MessageBird Verify API
    participant Phone as User's Phone

    User->>Frontend: 1. Enter phone number
    Frontend->>Backend: 2. POST /api/otp/generate {phone}
    Backend->>MessageBird: 3. Create Verify object
    MessageBird->>Phone: 4. Send SMS with OTP code
    MessageBird-->>Backend: 5. Return verify ID, status
    Backend-->>Frontend: 6. Return {verifyId, expiry}
    Frontend->>User: 7. Show OTP input form

    User->>Frontend: 8. Enter OTP code
    Frontend->>Backend: 9. POST /api/otp/verify {verifyId, token}
    Backend->>MessageBird: 10. Verify token
    MessageBird-->>Backend: 11. Return status (verified/failed)
    Backend-->>Frontend: 12. Return success/error
    Frontend->>User: 13. Show result (access granted/denied)

Prerequisites:

  • Node.js and npm: Version 14.x or higher (nodejs.org)
  • MessageBird Account: Free account with API key from MessageBird Dashboard (includes free test credits)
  • MessageBird Live API Key: Required for Verify API (test keys don't support verification). Create at Dashboard → Developers → API access (REST)
  • Phone Number Format: E.164 international format required (e.g., +14155552671 for US, +447700900000 for UK)
  • Basic Knowledge: JavaScript, async/await, RESTful APIs, React or Vue basics
  • (Optional) Testing Tools: Postman or curl for backend API testing

Expected Outcome:

  • Backend server with three OTP endpoints handling generation, verification, and status checks
  • Frontend application with phone input form and OTP code entry interface
  • Complete security implementation with rate limiting, attempt tracking, and expiration handling
  • Production-ready error handling for common failures (invalid numbers, expired codes, max attempts exceeded)

1. Setting up the Backend Project

Initialize the Node.js backend with MessageBird SDK and security dependencies.

Backend Setup Steps

  1. Create Backend Directory:

    bash
    mkdir messagebird-otp-backend
    cd messagebird-otp-backend
    npm init -y
  2. Enable ES Modules: Edit package.json to add:

    json
    {
      "type": "module",
      "scripts": {
        "dev": "nodemon src/server.js",
        "start": "node src/server.js"
      }
    }
  3. Install Backend Dependencies:

    bash
    npm install express messagebird dotenv express-rate-limit cors
    npm install -D nodemon
    • messagebird: Official SDK (v4.0.1, npm package)
    • express-rate-limit: Prevent brute-force OTP guessing attacks
    • cors: Enable frontend-backend communication during development
  4. Create Project Structure:

    bash
    mkdir src
    touch src/server.js src/messagebirdService.js .env .gitignore

    Structure:

    messagebird-otp-backend/ ├── node_modules/ ├── src/ │ ├── server.js # Express server with OTP endpoints │ └── messagebirdService.js # MessageBird Verify API wrapper ├── .env # API keys (DO NOT COMMIT) ├── .gitignore ├── package.json └── package-lock.json
  5. Configure .gitignore:

    text
    node_modules
    .env
    .DS_Store
  6. Configure .env:

    env
    # MessageBird Credentials
    MESSAGEBIRD_API_KEY=YOUR_LIVE_API_KEY_HERE
    
    # OTP Configuration
    OTP_TIMEOUT=300              # 5 minutes (300 seconds)
    OTP_TOKEN_LENGTH=6           # 6-digit codes
    OTP_MAX_ATTEMPTS=3           # Maximum verification attempts
    
    # Server Configuration
    PORT=3001
    NODE_ENV=development

    Important: MessageBird Verify API requires a live API key (not test mode). Get yours at MessageBird Dashboard → Developers → API access. According to MessageBird's Verify API documentation, test keys do not support verification functionality (effective as of 2024).

2. Implementing MessageBird Verify Service

Create a service module to interact with MessageBird's Verify API for generating and verifying OTP codes.

Edit src/messagebirdService.js:

javascript
// src/messagebirdService.js
import messagebird from 'messagebird';
import dotenv from 'dotenv';

dotenv.config();

// Validate API key presence
if (!process.env.MESSAGEBIRD_API_KEY) {
  console.error('Error: MESSAGEBIRD_API_KEY is not set in .env file');
  process.exit(1);
}

// Initialize MessageBird client
const client = messagebird(process.env.MESSAGEBIRD_API_KEY);

// Configuration from environment variables with defaults
const OTP_CONFIG = {
  timeout: parseInt(process.env.OTP_TIMEOUT) || 300,        // Default: 5 minutes
  tokenLength: parseInt(process.env.OTP_TOKEN_LENGTH) || 6, // Default: 6 digits
  maxAttempts: parseInt(process.env.OTP_MAX_ATTEMPTS) || 3  // Default: 3 attempts
};

/**
 * Generate and send OTP code to phone number using MessageBird Verify API
 * @param {string} phoneNumber - E.164 format phone number (e.g., +14155552671)
 * @returns {Promise<object>} - Verify object with id, status, validUntil
 * @throws {Error} - If verification creation fails
 */
export const generateOTP = async (phoneNumber) => {
  return new Promise((resolve, reject) => {
    // Create verification request with MessageBird
    client.verify.create(phoneNumber, {
      originator: 'YourApp',                              // Sender ID (max 11 chars, alphanumeric not supported in US)
      template: 'Your verification code is %token.',      // %token replaced with generated code
      type: 'sms',                                        // Options: sms, flash, tts (voice), email
      timeout: OTP_CONFIG.timeout,                        // Expiration time in seconds (30-172800)
      tokenLength: OTP_CONFIG.tokenLength,                // Code length (6-10 digits)
      datacoding: 'plain'                                 // Options: plain (GSM), unicode, auto
    }, (error, response) => {
      if (error) {
        console.error('MessageBird Verify creation error:', error);
        // Common errors: invalid number format, insufficient balance, API key issues
        return reject(new Error(error.errors?.[0]?.description || 'Failed to generate OTP'));
      }

      console.log('OTP generated successfully:', {
        id: response.id,
        recipient: response.recipient,
        status: response.status,
        validUntil: response.validUntilDatetime
      });

      resolve({
        verifyId: response.id,
        status: response.status,
        validUntil: response.validUntilDatetime,
        recipient: response.recipient
      });
    });
  });
};

/**
 * Verify OTP code entered by user
 * @param {string} verifyId - The verification ID from generateOTP
 * @param {string} token - The code entered by the user
 * @returns {Promise<object>} - Verification result with status
 * @throws {Error} - If verification fails (wrong code, expired, max attempts)
 */
export const verifyOTP = async (verifyId, token) => {
  return new Promise((resolve, reject) => {
    client.verify.verify(verifyId, token, (error, response) => {
      if (error) {
        console.error('MessageBird verification error:', error);
        // Common errors: token mismatch, expired verification, too many attempts
        const errorMsg = error.errors?.[0]?.description || 'Verification failed';
        return reject(new Error(errorMsg));
      }

      console.log('OTP verified successfully:', {
        id: response.id,
        status: response.status
      });

      resolve({
        verifyId: response.id,
        status: response.status,    // Will be "verified" on success
        recipient: response.recipient
      });
    });
  });
};

/**
 * Get current status of a verification (for checking expiry, attempts)
 * @param {string} verifyId - The verification ID
 * @returns {Promise<object>} - Verification object with current status
 */
export const getVerifyStatus = async (verifyId) => {
  return new Promise((resolve, reject) => {
    client.verify.view(verifyId, (error, response) => {
      if (error) {
        console.error('MessageBird verify status error:', error);
        return reject(new Error(error.errors?.[0]?.description || 'Failed to get status'));
      }

      resolve({
        verifyId: response.id,
        status: response.status,              // sent, verified, expired, failed
        createdAt: response.createdDatetime,
        validUntil: response.validUntilDatetime,
        recipient: response.recipient
      });
    });
  });
};

/**
 * Delete/cancel a verification (useful for cleanup or resend flows)
 * @param {string} verifyId - The verification ID to delete
 */
export const deleteVerification = async (verifyId) => {
  return new Promise((resolve, reject) => {
    client.verify.delete(verifyId, (error) => {
      if (error) {
        console.error('MessageBird verification deletion error:', error);
        return reject(new Error('Failed to delete verification'));
      }
      resolve({ deleted: true });
    });
  });
};

Key Implementation Notes:

  1. Timeout Range: MessageBird supports 30–172,800 seconds (30s to 2 days). For security, 5–10 minutes is recommended for most applications (NIST SP 800-63B suggests short-lived OTP tokens).

  2. Token Length: 6 digits provides 1 million combinations. With maxAttempts=3, brute-force probability is 0.0003% per verification session (3/1,000,000).

  3. Originator Restrictions: Alphanumeric sender IDs (e.g., "YourApp") are not supported in the United States and require pre-registration in many countries. Use a MessageBird virtual number for reliable delivery (MessageBird sender ID guide).

3. Building the API Endpoints

Create Express server with three OTP endpoints: generate, verify, and status.

Edit src/server.js:

javascript
// src/server.js
import express from 'express';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import dotenv from 'dotenv';
import { generateOTP, verifyOTP, getVerifyStatus } from './messagebirdService.js';

dotenv.config();

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

// Middleware
app.use(express.json());
app.use(cors({
  origin: process.env.NODE_ENV === 'production'
    ? 'https://your-frontend-domain.com'
    : 'http://localhost:5173'  // Vite default port
}));

// Rate limiting for OTP generation (prevent SMS bombing)
const otpGenerateLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,    // 15 minutes
  max: 5,                       // Max 5 OTP requests per phone per window
  message: { success: false, message: 'Too many OTP requests. Try again later.' },
  standardHeaders: true,
  legacyHeaders: false,
  keyGenerator: (req) => req.body.phoneNumber || req.ip  // Rate limit by phone number
});

// Rate limiting for OTP verification (prevent brute force)
const otpVerifyLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,    // 15 minutes
  max: 10,                      // Max 10 verification attempts per window
  message: { success: false, message: 'Too many verification attempts. Try again later.' },
  standardHeaders: true,
  legacyHeaders: false
});

// Health check endpoint
app.get('/health', (req, res) => {
  res.status(200).json({
    status: 'OK',
    timestamp: new Date().toISOString(),
    service: 'MessageBird OTP API'
  });
});

// --- OTP Generation Endpoint ---
app.post('/api/otp/generate', otpGenerateLimiter, async (req, res) => {
  const { phoneNumber } = req.body;

  // Validate phone number presence
  if (!phoneNumber) {
    return res.status(400).json({
      success: false,
      message: 'Phone number is required'
    });
  }

  // Validate E.164 format (starts with +, followed by 10-15 digits)
  const e164Regex = /^\+[1-9]\d{10,14}$/;
  if (!e164Regex.test(phoneNumber)) {
    return res.status(400).json({
      success: false,
      message: 'Invalid phone number format. Use E.164 format (e.g., +14155552671)'
    });
  }

  try {
    const result = await generateOTP(phoneNumber);

    res.status(200).json({
      success: true,
      message: 'OTP sent successfully',
      data: {
        verifyId: result.verifyId,
        validUntil: result.validUntil,
        status: result.status
      }
    });
  } catch (error) {
    console.error('OTP generation error:', error.message);
    res.status(500).json({
      success: false,
      message: 'Failed to send OTP',
      error: process.env.NODE_ENV === 'development' ? error.message : undefined
    });
  }
});

// --- OTP Verification Endpoint ---
app.post('/api/otp/verify', otpVerifyLimiter, async (req, res) => {
  const { verifyId, token } = req.body;

  // Validate inputs
  if (!verifyId || !token) {
    return res.status(400).json({
      success: false,
      message: 'Verification ID and token are required'
    });
  }

  // Validate token format (should be numeric, length matches config)
  const tokenLength = parseInt(process.env.OTP_TOKEN_LENGTH) || 6;
  if (!/^\d+$/.test(token) || token.length !== tokenLength) {
    return res.status(400).json({
      success: false,
      message: `Token must be ${tokenLength} digits`
    });
  }

  try {
    const result = await verifyOTP(verifyId, token);

    res.status(200).json({
      success: true,
      message: 'Phone number verified successfully',
      data: {
        verifyId: result.verifyId,
        status: result.status,
        recipient: result.recipient
      }
    });
  } catch (error) {
    console.error('OTP verification error:', error.message);

    // Provide specific error messages for common failures
    let statusCode = 400;
    let message = 'Verification failed';

    if (error.message.includes('token does not match') || error.message.includes('invalid')) {
      message = 'Invalid verification code';
    } else if (error.message.includes('expired')) {
      message = 'Verification code has expired';
      statusCode = 410;  // 410 Gone
    } else if (error.message.includes('attempts')) {
      message = 'Maximum verification attempts exceeded';
      statusCode = 429;  // 429 Too Many Requests
    }

    res.status(statusCode).json({
      success: false,
      message,
      error: process.env.NODE_ENV === 'development' ? error.message : undefined
    });
  }
});

// --- OTP Status Check Endpoint ---
app.get('/api/otp/status/:verifyId', async (req, res) => {
  const { verifyId } = req.params;

  if (!verifyId) {
    return res.status(400).json({
      success: false,
      message: 'Verification ID is required'
    });
  }

  try {
    const result = await getVerifyStatus(verifyId);

    res.status(200).json({
      success: true,
      data: result
    });
  } catch (error) {
    console.error('Status check error:', error.message);
    res.status(404).json({
      success: false,
      message: 'Verification not found or expired'
    });
  }
});

// Error handling middleware
app.use((err, req, res, next) => {
  console.error('Unhandled error:', err);
  res.status(500).json({
    success: false,
    message: 'Internal server error'
  });
});

// Start server
app.listen(PORT, () => {
  console.log(`✅ MessageBird OTP API running on http://localhost:${PORT}`);
  console.log(`📱 Endpoints available:`);
  console.log(`   POST http://localhost:${PORT}/api/otp/generate`);
  console.log(`   POST http://localhost:${PORT}/api/otp/verify`);
  console.log(`   GET  http://localhost:${PORT}/api/otp/status/:verifyId`);
});

Security Implementation Details:

  1. Phone-based Rate Limiting: The generate endpoint limits requests by phone number (5 per 15 min) to prevent SMS bombing attacks where attackers flood a number with verification codes.

  2. Attempt Limiting: MessageBird tracks verification attempts server-side (configurable 1–10 via maxAttempts). Combined with API rate limiting (10 verify requests per 15 min), this provides defense-in-depth against brute-force attacks.

  3. Specific Error Responses: Different HTTP status codes help clients handle failures appropriately (400 for invalid input, 410 for expired codes, 429 for rate limits).

4. Frontend Setup with Vite

Create a modern frontend using Vite with either React or Vue for the OTP interface.

Choose Your Framework:

Option A: React with Vite

bash
cd ..
npm create vite@latest messagebird-otp-frontend -- --template react
cd messagebird-otp-frontend
npm install
npm install axios

Option B: Vue 3 with Vite

bash
cd ..
npm create vite@latest messagebird-otp-frontend -- --template vue
cd messagebird-otp-frontend
npm install
npm install axios

Frontend Project Structure

messagebird-otp-frontend/ ├── node_modules/ ├── public/ ├── src/ │ ├── components/ │ │ ├── PhoneInput.jsx (or .vue) # Phone number entry form │ │ ├── OTPInput.jsx (or .vue) # OTP code entry with auto-focus │ │ └── StatusMessage.jsx (or .vue) # Success/error display │ ├── services/ │ │ └── api.js # Axios API client │ ├── App.jsx (or .vue) # Main application component │ └── main.jsx (or .js) ├── .env ├── package.json └── vite.config.js

Configure API Endpoint

Create .env in frontend root:

env
VITE_API_URL=http://localhost:3001

Note: Vite requires VITE_ prefix for environment variables to be exposed to client code (Vite env documentation).

5. Building the Frontend Components

API Service Layer

Create src/services/api.js:

javascript
// src/services/api.js
import axios from 'axios';

const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';

const apiClient = axios.create({
  baseURL: API_BASE_URL,
  headers: {
    'Content-Type': 'application/json'
  },
  timeout: 10000  // 10 second timeout
});

export const otpAPI = {
  /**
   * Request OTP code for phone number
   * @param {string} phoneNumber - E.164 format
   * @returns {Promise<{verifyId, validUntil, status}>}
   */
  generateOTP: async (phoneNumber) => {
    const response = await apiClient.post('/api/otp/generate', { phoneNumber });
    return response.data;
  },

  /**
   * Verify OTP code entered by user
   * @param {string} verifyId - Verification ID from generateOTP
   * @param {string} token - User-entered code
   * @returns {Promise<{status, recipient}>}
   */
  verifyOTP: async (verifyId, token) => {
    const response = await apiClient.post('/api/otp/verify', { verifyId, token });
    return response.data;
  },

  /**
   * Check verification status
   * @param {string} verifyId
   * @returns {Promise<{status, validUntil, createdAt}>}
   */
  getStatus: async (verifyId) => {
    const response = await apiClient.get(`/api/otp/status/${verifyId}`);
    return response.data;
  }
};

React Implementation

React: PhoneInput Component

Create src/components/PhoneInput.jsx:

jsx
// src/components/PhoneInput.jsx
import { useState } from 'react';

export default function PhoneInput({ onSubmit, loading }) {
  const [phoneNumber, setPhoneNumber] = useState('');
  const [error, setError] = useState('');

  const validatePhone = (phone) => {
    // E.164 format validation
    const e164Regex = /^\+[1-9]\d{10,14}$/;
    return e164Regex.test(phone);
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    setError('');

    if (!phoneNumber) {
      setError('Phone number is required');
      return;
    }

    if (!validatePhone(phoneNumber)) {
      setError('Invalid format. Use E.164 (e.g., +14155552671)');
      return;
    }

    onSubmit(phoneNumber);
  };

  return (
    <div className="phone-input-container">
      <h2>Enter Your Phone Number</h2>
      <p>We'll send you a verification code via SMS</p>

      <form onSubmit={handleSubmit}>
        <input
          type="tel"
          value={phoneNumber}
          onChange={(e) => setPhoneNumber(e.target.value)}
          placeholder="+1 415 555 2671"
          disabled={loading}
          autoFocus
          className="phone-input"
        />

        {error && <p className="error-message">{error}</p>}

        <button type="submit" disabled={loading} className="submit-button">
          {loading ? 'Sending…' : 'Send Code'}
        </button>
      </form>

      <p className="help-text">
        Format: +[country code][number] (e.g., +14155552671 for US)
      </p>
    </div>
  );
}

React: OTPInput Component

Create src/components/OTPInput.jsx:

jsx
// src/components/OTPInput.jsx
import { useState, useRef, useEffect } from 'react';

export default function OTPInput({
  length = 6,
  onComplete,
  onResend,
  loading,
  expiryTime
}) {
  const [otp, setOtp] = useState(new Array(length).fill(''));
  const [timeLeft, setTimeLeft] = useState(null);
  const inputRefs = useRef([]);

  // Calculate time remaining
  useEffect(() => {
    if (!expiryTime) return;

    const interval = setInterval(() => {
      const now = new Date().getTime();
      const expiry = new Date(expiryTime).getTime();
      const remaining = Math.max(0, Math.floor((expiry - now) / 1000));

      setTimeLeft(remaining);

      if (remaining === 0) {
        clearInterval(interval);
      }
    }, 1000);

    return () => clearInterval(interval);
  }, [expiryTime]);

  const handleChange = (index, value) => {
    // Only allow digits
    if (!/^\d*$/.test(value)) return;

    const newOtp = [...otp];
    newOtp[index] = value.slice(-1);  // Only last character
    setOtp(newOtp);

    // Auto-focus next input
    if (value && index < length - 1) {
      inputRefs.current[index + 1]?.focus();
    }

    // Auto-submit when complete
    if (newOtp.every(digit => digit !== '') && !loading) {
      onComplete(newOtp.join(''));
    }
  };

  const handleKeyDown = (index, e) => {
    // Handle backspace to move to previous input
    if (e.key === 'Backspace' && !otp[index] && index > 0) {
      inputRefs.current[index - 1]?.focus();
    }
  };

  const handlePaste = (e) => {
    e.preventDefault();
    const pastedData = e.clipboardData.getData('text').slice(0, length);

    if (!/^\d+$/.test(pastedData)) return;

    const newOtp = pastedData.split('').concat(new Array(length).fill('')).slice(0, length);
    setOtp(newOtp);

    // Focus last filled input or next empty
    const nextIndex = Math.min(pastedData.length, length - 1);
    inputRefs.current[nextIndex]?.focus();

    if (newOtp.every(digit => digit !== '')) {
      onComplete(newOtp.join(''));
    }
  };

  const formatTime = (seconds) => {
    const mins = Math.floor(seconds / 60);
    const secs = seconds % 60;
    return `${mins}:${secs.toString().padStart(2, '0')}`;
  };

  return (
    <div className="otp-input-container">
      <h2>Enter Verification Code</h2>
      <p>We sent a {length}-digit code to your phone</p>

      <div className="otp-inputs">
        {otp.map((digit, index) => (
          <input
            key={index}
            ref={(el) => (inputRefs.current[index] = el)}
            type="text"
            inputMode="numeric"
            maxLength={1}
            value={digit}
            onChange={(e) => handleChange(index, e.target.value)}
            onKeyDown={(e) => handleKeyDown(index, e)}
            onPaste={handlePaste}
            disabled={loading || timeLeft === 0}
            className="otp-digit"
            autoFocus={index === 0}
          />
        ))}
      </div>

      {timeLeft !== null && (
        <p className={`timer ${timeLeft < 30 ? 'warning' : ''}`}>
          {timeLeft > 0
            ? `Code expires in ${formatTime(timeLeft)}`
            : 'Code has expired'}
        </p>
      )}

      <button
        onClick={onResend}
        disabled={loading || timeLeft > 0}
        className="resend-button"
      >
        {timeLeft > 0 ? 'Resend available after expiry' : 'Resend Code'}
      </button>
    </div>
  );
}

React: Main App Component

Edit src/App.jsx:

jsx
// src/App.jsx
import { useState } from 'react';
import PhoneInput from './components/PhoneInput';
import OTPInput from './components/OTPInput';
import { otpAPI } from './services/api';
import './App.css';

function App() {
  const [step, setStep] = useState('phone'); // 'phone', 'otp', 'success', 'error'
  const [phoneNumber, setPhoneNumber] = useState('');
  const [verifyId, setVerifyId] = useState('');
  const [validUntil, setValidUntil] = useState('');
  const [loading, setLoading] = useState(false);
  const [message, setMessage] = useState('');

  const handlePhoneSubmit = async (phone) => {
    setLoading(true);
    setMessage('');

    try {
      const response = await otpAPI.generateOTP(phone);

      if (response.success) {
        setPhoneNumber(phone);
        setVerifyId(response.data.verifyId);
        setValidUntil(response.data.validUntil);
        setStep('otp');
        setMessage('Code sent! Check your phone.');
      }
    } catch (error) {
      setMessage(error.response?.data?.message || 'Failed to send code. Try again.');
    } finally {
      setLoading(false);
    }
  };

  const handleOTPComplete = async (code) => {
    setLoading(true);
    setMessage('');

    try {
      const response = await otpAPI.verifyOTP(verifyId, code);

      if (response.success) {
        setStep('success');
        setMessage('✅ Phone number verified successfully!');
      }
    } catch (error) {
      const errorMsg = error.response?.data?.message || 'Verification failed';
      setMessage(`${errorMsg}`);

      // Show error state but stay on OTP screen for retry
      if (error.response?.status === 410 || error.response?.status === 429) {
        // Expired or max attempts – go back to phone input
        setTimeout(() => {
          setStep('phone');
          setMessage('');
        }, 3000);
      }
    } finally {
      setLoading(false);
    }
  };

  const handleResend = () => {
    setStep('phone');
    setMessage('');
    // Optionally pre-fill phone number
  };

  const handleReset = () => {
    setStep('phone');
    setPhoneNumber('');
    setVerifyId('');
    setValidUntil('');
    setMessage('');
  };

  return (
    <div className="app-container">
      <div className="card">
        <h1>🔐 Phone Verification</h1>

        {message && (
          <div className={`message ${step === 'success' ? 'success' : 'error'}`}>
            {message}
          </div>
        )}

        {step === 'phone' && (
          <PhoneInput onSubmit={handlePhoneSubmit} loading={loading} />
        )}

        {step === 'otp' && (
          <OTPInput
            length={6}
            onComplete={handleOTPComplete}
            onResend={handleResend}
            loading={loading}
            expiryTime={validUntil}
          />
        )}

        {step === 'success' && (
          <div className="success-container">
            <div className="success-icon"></div>
            <h2>Verification Successful!</h2>
            <p>Your phone number {phoneNumber} has been verified.</p>
            <button onClick={handleReset} className="button">
              Verify Another Number
            </button>
          </div>
        )}
      </div>
    </div>
  );
}

export default App;

Vue 3 Implementation

Vue: PhoneInput Component

Create src/components/PhoneInput.vue:

vue
<template>
  <div class="phone-input-container">
    <h2>Enter Your Phone Number</h2>
    <p>We'll send you a verification code via SMS</p>

    <form @submit.prevent="handleSubmit">
      <input
        v-model="phoneNumber"
        type="tel"
        placeholder="+1 415 555 2671"
        :disabled="loading"
        autofocus
        class="phone-input"
      />

      <p v-if="error" class="error-message">{{ error }}</p>

      <button type="submit" :disabled="loading" class="submit-button">
        {{ loading ? 'Sending…' : 'Send Code' }}
      </button>
    </form>

    <p class="help-text">
      Format: +[country code][number] (e.g., +14155552671 for US)
    </p>
  </div>
</template>

<script setup>
import { ref } from 'vue';

const props = defineProps({
  loading: Boolean
});

const emit = defineEmits(['submit']);

const phoneNumber = ref('');
const error = ref('');

const validatePhone = (phone) => {
  const e164Regex = /^\+[1-9]\d{10,14}$/;
  return e164Regex.test(phone);
};

const handleSubmit = () => {
  error.value = '';

  if (!phoneNumber.value) {
    error.value = 'Phone number is required';
    return;
  }

  if (!validatePhone(phoneNumber.value)) {
    error.value = 'Invalid format. Use E.164 (e.g., +14155552671)';
    return;
  }

  emit('submit', phoneNumber.value);
};
</script>

Vue: OTPInput Component

Create src/components/OTPInput.vue:

vue
<template>
  <div class="otp-input-container">
    <h2>Enter Verification Code</h2>
    <p>We sent a {{ length }}-digit code to your phone</p>

    <div class="otp-inputs">
      <input
        v-for="(digit, index) in otp"
        :key="index"
        :ref="el => inputRefs[index] = el"
        v-model="otp[index]"
        type="text"
        inputmode="numeric"
        maxlength="1"
        @input="handleInput(index, $event)"
        @keydown="handleKeyDown(index, $event)"
        @paste="handlePaste"
        :disabled="loading || timeLeft === 0"
        class="otp-digit"
        :autofocus="index === 0"
      />
    </div>

    <p v-if="timeLeft !== null" :class="['timer', { warning: timeLeft < 30 }]">
      {{ timeLeft > 0 ? `Code expires in ${formatTime(timeLeft)}` : 'Code has expired' }}
    </p>

    <button
      @click="$emit('resend')"
      :disabled="loading || timeLeft > 0"
      class="resend-button"
    >
      {{ timeLeft > 0 ? 'Resend available after expiry' : 'Resend Code' }}
    </button>
  </div>
</template>

<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue';

const props = defineProps({
  length: { type: Number, default: 6 },
  loading: Boolean,
  expiryTime: String
});

const emit = defineEmits(['complete', 'resend']);

const otp = ref(new Array(props.length).fill(''));
const timeLeft = ref(null);
const inputRefs = ref([]);
let timerInterval = null;

const handleInput = (index, event) => {
  const value = event.target.value;

  if (!/^\d*$/.test(value)) {
    otp.value[index] = '';
    return;
  }

  otp.value[index] = value.slice(-1);

  if (value && index < props.length - 1) {
    inputRefs.value[index + 1]?.focus();
  }

  if (otp.value.every(d => d !== '') && !props.loading) {
    emit('complete', otp.value.join(''));
  }
};

const handleKeyDown = (index, event) => {
  if (event.key === 'Backspace' && !otp.value[index] && index > 0) {
    inputRefs.value[index - 1]?.focus();
  }
};

const handlePaste = (event) => {
  event.preventDefault();
  const pastedData = event.clipboardData.getData('text').slice(0, props.length);

  if (!/^\d+$/.test(pastedData)) return;

  const newOtp = pastedData.split('').concat(new Array(props.length).fill('')).slice(0, props.length);
  otp.value = newOtp;

  const nextIndex = Math.min(pastedData.length, props.length - 1);
  inputRefs.value[nextIndex]?.focus();

  if (newOtp.every(d => d !== '')) {
    emit('complete', newOtp.join(''));
  }
};

const formatTime = (seconds) => {
  const mins = Math.floor(seconds / 60);
  const secs = seconds % 60;
  return `${mins}:${secs.toString().padStart(2, '0')}`;
};

watch(() => props.expiryTime, (newExpiry) => {
  if (!newExpiry) return;

  if (timerInterval) clearInterval(timerInterval);

  timerInterval = setInterval(() => {
    const now = new Date().getTime();
    const expiry = new Date(newExpiry).getTime();
    const remaining = Math.max(0, Math.floor((expiry - now) / 1000));

    timeLeft.value = remaining;

    if (remaining === 0) {
      clearInterval(timerInterval);
    }
  }, 1000);
}, { immediate: true });

onUnmounted(() => {
  if (timerInterval) clearInterval(timerInterval);
});
</script>

Vue: Main App Component

Edit src/App.vue:

vue
<template>
  <div class="app-container">
    <div class="card">
      <h1>🔐 Phone Verification</h1>

      <div v-if="message" :class="['message', step === 'success' ? 'success' : 'error']">
        {{ message }}
      </div>

      <PhoneInput
        v-if="step === 'phone'"
        :loading="loading"
        @submit="handlePhoneSubmit"
      />

      <OTPInput
        v-if="step === 'otp'"
        :length="6"
        :loading="loading"
        :expiry-time="validUntil"
        @complete="handleOTPComplete"
        @resend="handleResend"
      />

      <div v-if="step === 'success'" class="success-container">
        <div class="success-icon">✓</div>
        <h2>Verification Successful!</h2>
        <p>Your phone number {{ phoneNumber }} has been verified.</p>
        <button @click="handleReset" class="button">
          Verify Another Number
        </button>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import PhoneInput from './components/PhoneInput.vue';
import OTPInput from './components/OTPInput.vue';
import { otpAPI } from './services/api';

const step = ref('phone');
const phoneNumber = ref('');
const verifyId = ref('');
const validUntil = ref('');
const loading = ref(false);
const message = ref('');

const handlePhoneSubmit = async (phone) => {
  loading.value = true;
  message.value = '';

  try {
    const response = await otpAPI.generateOTP(phone);

    if (response.success) {
      phoneNumber.value = phone;
      verifyId.value = response.data.verifyId;
      validUntil.value = response.data.validUntil;
      step.value = 'otp';
      message.value = 'Code sent! Check your phone.';
    }
  } catch (error) {
    message.value = error.response?.data?.message || 'Failed to send code. Try again.';
  } finally {
    loading.value = false;
  }
};

const handleOTPComplete = async (code) => {
  loading.value = true;
  message.value = '';

  try {
    const response = await otpAPI.verifyOTP(verifyId.value, code);

    if (response.success) {
      step.value = 'success';
      message.value = '✅ Phone number verified successfully!';
    }
  } catch (error) {
    const errorMsg = error.response?.data?.message || 'Verification failed';
    message.value = `❌ ${errorMsg}`;

    if (error.response?.status === 410 || error.response?.status === 429) {
      setTimeout(() => {
        step.value = 'phone';
        message.value = '';
      }, 3000);
    }
  } finally {
    loading.value = false;
  }
};

const handleResend = () => {
  step.value = 'phone';
  message.value = '';
};

const handleReset = () => {
  step.value = 'phone';
  phoneNumber.value = '';
  verifyId.value = '';
  validUntil.value = '';
  message.value = '';
};
</script>

6. Adding Styles

Create src/App.css (works for both React and Vue):

css
/* src/App.css */
* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
}

.app-container {
  width: 100%;
  max-width: 500px;
}

.card {
  background: white;
  border-radius: 16px;
  padding: 40px;
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}

h1 {
  text-align: center;
  color: #333;
  margin-bottom: 30px;
  font-size: 24px;
}

h2 {
  color: #444;
  margin-bottom: 10px;
  font-size: 20px;
}

p {
  color: #666;
  margin-bottom: 20px;
  line-height: 1.5;
}

.message {
  padding: 12px 16px;
  border-radius: 8px;
  margin-bottom: 20px;
  font-weight: 500;
}

.message.success {
  background-color: #d4edda;
  color: #155724;
  border: 1px solid #c3e6cb;
}

.message.error {
  background-color: #f8d7da;
  color: #721c24;
  border: 1px solid #f5c6cb;
}

.phone-input-container,
.otp-input-container {
  text-align: center;
}

.phone-input {
  width: 100%;
  padding: 14px 16px;
  font-size: 16px;
  border: 2px solid #ddd;
  border-radius: 8px;
  margin-bottom: 10px;
  transition: border-color 0.3s;
}

.phone-input:focus {
  outline: none;
  border-color: #667eea;
}

.phone-input:disabled {
  background-color: #f5f5f5;
  cursor: not-allowed;
}

.otp-inputs {
  display: flex;
  gap: 12px;
  justify-content: center;
  margin: 20px 0;
}

.otp-digit {
  width: 50px;
  height: 60px;
  font-size: 24px;
  text-align: center;
  border: 2px solid #ddd;
  border-radius: 8px;
  transition: all 0.3s;
  font-weight: 600;
}

.otp-digit:focus {
  outline: none;
  border-color: #667eea;
  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}

.otp-digit:disabled {
  background-color: #f5f5f5;
  cursor: not-allowed;
}

.timer {
  font-size: 14px;
  margin: 15px 0;
  font-weight: 500;
}

.timer.warning {
  color: #dc3545;
}

.submit-button,
.resend-button,
.button {
  width: 100%;
  padding: 14px;
  font-size: 16px;
  font-weight: 600;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.3s;
  margin-top: 10px;
}

.submit-button,
.button {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
}

.submit-button:hover:not(:disabled),
.button:hover:not(:disabled) {
  transform: translateY(-2px);
  box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}

.submit-button:disabled,
.resend-button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.resend-button {
  background: white;
  color: #667eea;
  border: 2px solid #667eea;
}

.resend-button:hover:not(:disabled) {
  background: #f8f9ff;
}

.error-message {
  color: #dc3545;
  font-size: 14px;
  margin-top: -10px;
  margin-bottom: 15px;
}

.help-text {
  font-size: 13px;
  color: #999;
  margin-top: 10px;
}

.success-container {
  text-align: center;
  padding: 20px 0;
}

.success-icon {
  width: 80px;
  height: 80px;
  background: #28a745;
  color: white;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 48px;
  margin: 0 auto 20px;
}

@media (max-width: 600px) {
  .card {
    padding: 30px 20px;
  }

  .otp-digit {
    width: 40px;
    height: 50px;
    font-size: 20px;
  }

  .otp-inputs {
    gap: 8px;
  }
}

7. Testing the Complete Application

Backend Testing

  1. Start Backend Server:

    bash
    cd messagebird-otp-backend
    npm run dev
  2. Test with curl:

    Generate OTP:

    bash
    curl -X POST http://localhost:3001/api/otp/generate \
      -H "Content-Type: application/json" \
      -d '{"phoneNumber":"+14155552671"}'

    Response:

    json
    {
      "success": true,
      "message": "OTP sent successfully",
      "data": {
        "verifyId": "4e213b01155d1e35a9d9571v00162985",
        "validUntil": "2024-01-15T14:35:00Z",
        "status": "sent"
      }
    }

    Verify OTP:

    bash
    curl -X POST http://localhost:3001/api/otp/verify \
      -H "Content-Type: application/json" \
      -d '{"verifyId":"4e213b01155d1e35a9d9571v00162985","token":"123456"}'

Frontend Testing

  1. Start Frontend Dev Server:

    bash
    cd messagebird-otp-frontend
    npm run dev

    Opens at http://localhost:5173

  2. Test Flow:

    • Enter your real phone number in E.164 format (e.g., +14155552671)
    • Click "Send Code"
    • Check your phone for SMS (arrives in 5–30 seconds typically)
    • Enter the 6-digit code in the OTP inputs
    • Verify success message displays

Common Testing Issues

IssueCauseSolution
"Failed to generate OTP"Invalid API keyVerify MESSAGEBIRD_API_KEY is live key (not test) in backend .env
"Invalid phone number format"Wrong formatUse E.164: +[country code][number] without spaces
SMS not receivedTrial account restrictionsMessageBird trials may have destination restrictions (check dashboard)
"Code has expired"Default 30s timeout too shortIncrease OTP_TIMEOUT in backend .env to 300 (5 minutes)
CORS errorsFrontend/backend mismatchVerify VITE_API_URL in frontend .env matches backend PORT
"Too many requests"Rate limiter triggeredWait 15 minutes or adjust limits in server.js for testing

8. Security Best Practices

Production Security Checklist

  1. Rate Limiting Configuration:

    • Generation endpoint: 3–5 requests per phone number per hour (prevents SMS bombing)
    • Verification endpoint: 10 attempts per IP per 15 minutes (prevents brute force)
    • Use Redis-backed rate limiter for distributed systems (rate-limit-redis)
  2. OTP Configuration:

    • Token length: Minimum 6 digits (1M combinations)
    • Timeout: 5–10 minutes for UX balance (per OWASP Authentication Cheat Sheet)
    • Max attempts: 3 attempts per verification session
    • One-time use: MessageBird Verify API automatically invalidates tokens after successful verification
  3. HTTPS Requirements:

    • Production: Always use HTTPS for both frontend and backend (prevent MITM attacks)
    • Development: Use mkcert or similar for local HTTPS testing
    • Enforce HTTPS redirects in production (use Helmet.js hsts middleware)
  4. API Key Protection:

    • Never expose MessageBird API keys in frontend code
    • Use environment variables on server
    • Rotate keys quarterly or after suspected compromise
    • Use separate keys for staging/production environments
  5. Logging and Monitoring:

    • Log failed verification attempts (security event monitoring)
    • Alert on unusual patterns (e.g., 100+ OTP requests from single IP)
    • Never log full phone numbers in production (use masking: +1415***2671)
    • Track metrics: generation success rate, verification success rate, average time to verify
  6. GDPR/Privacy Compliance:

    • Obtain user consent before sending OTP messages (GDPR Article 6)
    • Implement data retention policies (delete verification records after 30–90 days)
    • Provide opt-out mechanisms for SMS communications
    • Honor "right to be forgotten" requests

Known Security Limitations

SMS-based OTP vulnerabilities (documented by NIST SP 800-63B):

  • SIM swapping attacks: Attacker ports victim's number to their SIM
  • SMS interception: Possible via SS7 protocol vulnerabilities
  • Phishing: Users may be tricked into revealing codes

Mitigation strategies:

  • Use MessageBird Verify API alongside app-based TOTP (e.g., Google Authenticator) for high-security applications
  • Implement device fingerprinting to detect suspicious logins
  • Monitor for rapid verification attempts from new locations
  • Consider MessageBird's WhatsApp or voice verification for higher security channels

9. Deployment Considerations

Backend Deployment (Node.js/Express)

Platform Options:

  • Heroku: Easy deployment with buildpacks (guide)
  • AWS Elastic Beanstalk: Managed Node.js hosting
  • DigitalOcean App Platform: Simple Node.js deployments
  • Railway: Modern platform with Git-based deployments

Environment Variables Setup:

bash
# Production environment variables
MESSAGEBIRD_API_KEY=your_live_key_here
OTP_TIMEOUT=300
OTP_TOKEN_LENGTH=6
OTP_MAX_ATTEMPTS=3
PORT=3001
NODE_ENV=production

Process Management: Use PM2 for production resilience:

bash
npm install -g pm2
pm2 start src/server.js --name "messagebird-otp-api"
pm2 save
pm2 startup

Frontend Deployment (Vite)

Platform Options:

  • Vercel: Automatic Vite detection, zero config (vercel.com)
  • Netlify: Drag-and-drop or Git-based (netlify.com)
  • Cloudflare Pages: Fast global CDN
  • AWS S3 + CloudFront: Full control, CDN included

Build and Deploy:

bash
# Build production bundle
npm run build

# Output in dist/ directory
# Deploy dist/ to hosting platform

Production Environment Variables: Create .env.production:

env
VITE_API_URL=https://your-backend-domain.com

CORS Configuration for Production

Update backend src/server.js CORS settings:

javascript
app.use(cors({
  origin: process.env.NODE_ENV === 'production'
    ? ['https://your-frontend-domain.com', 'https://www.your-frontend-domain.com']
    : 'http://localhost:5173',
  credentials: true
}));

10. Advanced Enhancements

Database Integration for Audit Trail

For production applications, track verification attempts in a database:

PostgreSQL Schema Example:

sql
CREATE TABLE otp_verifications (
  id SERIAL PRIMARY KEY,
  phone_number VARCHAR(20) NOT NULL,
  verify_id VARCHAR(100) NOT NULL,
  status VARCHAR(20) NOT NULL, -- sent, verified, expired, failed
  attempts INT DEFAULT 0,
  created_at TIMESTAMP DEFAULT NOW(),
  verified_at TIMESTAMP,
  expires_at TIMESTAMP NOT NULL,
  ip_address INET,
  user_agent TEXT
);

CREATE INDEX idx_phone_created ON otp_verifications(phone_number, created_at);
CREATE INDEX idx_verify_id ON otp_verifications(verify_id);

Benefits:

  • Audit trail for compliance (GDPR Article 30)
  • Security analytics (detect abuse patterns)
  • User support (verify code was sent to correct number)

Resend OTP Functionality

Add cooldown period to prevent abuse:

javascript
// Backend: Track last send time per phone number
const sendCooldown = new Map(); // Use Redis in production

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

  const lastSend = sendCooldown.get(phoneNumber);
  if (lastSend && Date.now() - lastSend < 60000) {
    return res.status(429).json({
      success: false,
      message: 'Wait 60 seconds before requesting another code'
    });
  }

  // Generate new OTP (same logic as /generate)
  const result = await generateOTP(phoneNumber);
  sendCooldown.set(phoneNumber, Date.now());

  res.status(200).json({ success: true, data: result });
});

Multi-Language Support

MessageBird Verify API supports 35+ languages for voice OTP (API documentation):

javascript
client.verify.create(phoneNumber, {
  type: 'tts',           // Text-to-speech voice call
  language: 'es-mx',     // Spanish (Mexico)
  voice: 'female',       // male or female
  // ... other options
});

Supported languages include: English (en-us, en-gb), Spanish (es-es, es-mx), French (fr-fr), German (de-de), Mandarin (zh-cn), Japanese (ja-jp), and many more.

Frequently Asked Questions

How does MessageBird Verify API generate OTP codes?

MessageBird Verify API automatically generates cryptographically secure random numeric codes (6–10 digits configurable) server-side when you create a verification object. The API handles token generation, storage, expiration, and delivery—you never see or manage the actual code in your backend, which eliminates implementation vulnerabilities (MessageBird Verify API docs).

What's the difference between MessageBird SMS API and Verify API?

The SMS API (client.messages.create()) sends arbitrary text messages and requires you to manually generate OTP codes, track expiration, and manage verification attempts. The Verify API (client.verify.create()) is purpose-built for OTP/2FA, automatically handling token generation, validation, expiration, and attempt limiting. Use Verify API for authentication flows and SMS API for notifications or marketing messages.

How long should OTP codes be valid?

According to NIST SP 800-63B guidelines (2023), OTP tokens should expire quickly to minimize attack windows. Recommended ranges: 5–10 minutes for standard authentication (balances security and UX), 30–60 seconds for high-security transactions (banking, medical records), 15–30 minutes for account recovery flows (users may need to check email for password reset). MessageBird Verify API supports 30 seconds to 2 days.

Can I use MessageBird Verify API with free trial accounts?

Yes, MessageBird provides free trial credits for testing, but you must use a live API key (not test mode key) for Verify API functionality. Trial accounts may have destination restrictions—verify that you can send to your test phone numbers in the MessageBird Dashboard. As of 2024, most trial accounts include €10–20 in free credits sufficient for 100–200 SMS verifications depending on destination country.

How do I prevent SMS bombing attacks on my OTP endpoint?

Implement multi-layer rate limiting: (1) Phone-number-based limits (3–5 OTP requests per number per hour) using express-rate-limit with custom keyGenerator, (2) IP-based limits (10 requests per IP per hour) as backup, (3) Consider CAPTCHA after 2 failed generation attempts, (4) Monitor for unusual patterns (alerts when >50 requests/hour from single source). MessageBird also applies account-level rate limits for additional protection.

What happens if a user enters the wrong OTP code multiple times?

MessageBird Verify API tracks verification attempts (configurable 1–10 via maxAttempts, default 3). After exceeding max attempts, the verification status changes to "failed" and the API returns an error on subsequent verify requests. The verification ID becomes invalid and the user must request a new OTP code. This prevents brute-force attacks (3 attempts = 0.0003% success probability with 6-digit codes).

Is SMS-based OTP secure enough for sensitive applications?

SMS OTP provides moderate security suitable for most applications (account login, e-commerce checkouts, identity verification). However, NIST SP 800-63B deprecates SMS for high-security contexts due to vulnerabilities (SIM swapping, SS7 interception, phishing). For banking, healthcare, or government applications, consider: (1) App-based TOTP (Google Authenticator, Authy), (2) Hardware tokens (YubiKey), (3) MessageBird's WhatsApp verification (more secure than SMS), or (4) Multi-factor authentication combining SMS + another factor.

How do I handle users in countries with poor SMS delivery?

MessageBird Verify API supports alternative channels: (1) Voice OTP (TTS): Call user and speak code aloud (set type: 'tts'), useful for areas with SMS blocking, (2) WhatsApp: More reliable delivery in developing markets (requires WhatsApp Business API integration), (3) Email: For non-mobile users (set type: 'email'). Monitor delivery rates by country in MessageBird Dashboard and automatically fallback to voice for countries with <95% SMS delivery success.

Can I customize the OTP message template?

Yes, MessageBird Verify API allows custom templates via the template parameter. The string must include %token placeholder which MessageBird replaces with the generated code. Example templates: "Your verification code is %token." (default), "Use code %token to log in to YourApp", "%token is your one-time password (valid 5 minutes)". Keep messages under 160 characters (GSM-7) or 70 characters (Unicode) to avoid multi-part SMS charges.

How do I implement OTP for user registration vs. login?

Registration flow: (1) User enters phone number during signup, (2) Send OTP via Verify API, (3) Verify code, (4) Create account with verified phone number in database. Login flow: (1) User enters username/password, (2) If credentials valid, send OTP to registered phone, (3) Verify code, (4) Issue session token/JWT. For enhanced security, require OTP on every login (2FA) or only for high-risk events (new device, unusual location, password change).

For comprehensive guides on SMS authentication and security:

Frequently Asked Questions

How to send SMS with Node.js and Express?

Use the Vonage Messages API with the Node.js SDK and Express. Create an API endpoint that accepts recipient details and message content, then uses the SDK to send the SMS via Vonage.

What is the Vonage Messages API?

It's a cloud-based API by Vonage that allows developers to send and receive SMS messages programmatically. This guide focuses on sending SMS using the Messages API via their Node.js Server SDK.

Why use Express for sending SMS in Node?

Express simplifies creating API endpoints to handle SMS requests, manage middleware, and handle HTTP requests/responses. This guide uses Express to build a /api/send-sms endpoint.

When should I use Vonage private key content vs. path?

Use the private key path for local development, as specified in your .env file with `VONAGE_PRIVATE_KEY_PATH`. Use the content variable, `VONAGE_PRIVATE_KEY_CONTENT`, in production environments for security, and populating the variable through a deployment pipeline, avoiding the need to store the key file directly on the server.

Can I use alphanumeric sender ID with Vonage?

Yes, you can use an alphanumeric sender ID (e.g., 'MyBrand') instead of a phone number. Note that support varies by country, and replies may not be possible. Use the `from` parameter in your API request as outlined in section 8. For this project, we'll default to your `VONAGE_NUMBER`.

How to set up Vonage application for SMS?

Log in to the Vonage Dashboard, create a new application, generate your keys (saving the private key securely), and link your Vonage number to the application. Then, set your credentials in your project's .env file. Be sure the default SMS API is set to "Messages API".

What is nodemon used for in this project?

Nodemon automatically restarts the Node.js server during development when code changes are detected, streamlining the development process. It's installed as a development dependency.

How to handle Vonage SMS errors in Node.js?

Implement try...catch blocks to handle errors during API calls. The Vonage SDK might provide specific error codes/messages you can use for more detailed error handling. For production, map these to user-friendly responses.

What is the purpose of a health check endpoint?

A health check endpoint (e.g., /health) allows monitoring systems to quickly check the status of your application. It typically returns a simple response like { status: 'OK' } if the server is running.

Why separate Vonage logic into a service module?

Separating concerns makes your code more organized, testable, and maintainable. It isolates the Vonage API interaction, simplifying your main server file (server.js) and allowing independent testing of the Vonage service logic.

How to secure Vonage SMS API endpoint in Node.js?

Use API keys, JWTs, or other authentication/authorization methods to protect your API endpoint. Also, implement input validation, rate limiting (e.g., with express-rate-limit), and use security headers (e.g., with helmet).

What are DLRs and how to handle them with Vonage?

DLRs (Delivery Receipts) provide delivery status updates (delivered, failed, etc.). Configure a 'Status URL' in your Vonage application settings. Implement an endpoint to receive POST requests from Vonage containing DLR information, using the message_uuid to correlate responses.

How to handle SMS character encoding and limits with Vonage?

Vonage largely handles encoding automatically, switching between GSM-7 (160 chars/segment) and UCS-2 (70 chars/segment) as needed. Be aware of these limits, especially for non-GSM characters (emojis, accented letters), as messages are split into segments and billing is per segment.

How to manage private key securely during Node.js deployment?

Never include the private key file (.env or private.key) in version control. In production, store the *content* of `private.key` in a secure environment variable like `VONAGE_PRIVATE_KEY_CONTENT` using your platform's secrets management features (e.g., Heroku Config Vars).