code examples
code examples
Vonage Node.js Express OTP/2FA SMS Implementation Guide
Complete guide for implementing production-ready SMS-based Two-Factor Authentication using Vonage Verify API with Node.js and Express framework
Framework Note: This guide demonstrates OTP/2FA implementation using Node.js with Express.js. While the title references Next.js and Supabase, this implementation focuses on Express.js backend patterns that adapt to Next.js API routes and integrate with Supabase Auth for user management. Express.js provides simplicity and widespread adoption in backend API development, making the core concepts transferable across frameworks.
This guide provides a step-by-step walkthrough for building a production-ready One-Time Password (OTP) Two-Factor Authentication (2FA) system using SMS delivery. You'll leverage Node.js with the Express framework and the Vonage Verify API for a robust and simplified implementation.
By the end of this tutorial, you'll have a functional application that:
- Accepts a user's phone number.
- Initiates an OTP request to that number via SMS using the Vonage Verify API.
- Allows the user to submit the received OTP code.
- Verifies the submitted code against the Vonage Verify API.
- Provides feedback on the verification status (success or failure).
This guide focuses on the core backend logic and API endpoints necessary for SMS OTP verification.
Project Overview and Goals
Goal: Implement a secure and reliable SMS-based 2FA mechanism for user verification within a Node.js/Express application.
Problem Solved: Standard password authentication remains vulnerable to multiple attack vectors including credential stuffing (automated injection of stolen username/password pairs), phishing attacks, brute-force attacks, and password reuse exploits (source: OWASP Multifactor Authentication Cheat Sheet). Microsoft research indicates that MFA would have prevented 99.9% of account compromises (source). Adding 2FA via SMS OTP significantly enhances security by requiring users to possess their registered phone ("something they have") in addition to their password ("something they know"). This guide provides the backend infrastructure for this second factor.
Security Limitations: While SMS-based 2FA is significantly better than password-only authentication, it remains susceptible to SIM swapping attacks, SMS interception, and phishing. For high-security applications, consider TOTP authenticator apps or hardware tokens (source: OWASP MFA Testing Guide).
Technologies Used:
- Node.js: A JavaScript runtime environment ideal for building scalable network applications. Minimum version required: Node.js 18 LTS (as of the latest @vonage/server-sdk requirements – source).
- Express.js: A minimal and flexible Node.js web application framework, providing robust features for web and mobile applications.
- Vonage Verify API: A purpose-built API by Vonage that handles OTP generation, delivery (including SMS, voice fallbacks, and retries), and verification logic, simplifying the developer's task.
@vonage/server-sdk: The official Vonage Node.js SDK for interacting with Vonage APIs, including the Verify API (npm package).dotenv: A module to load environment variables from a.envfile intoprocess.env.body-parser: Node.js middleware for parsing incoming request bodies. While newer versions of Express (4.16+) have built-in middleware (express.json(),express.urlencoded()), includingbody-parserexplicitly ensures compatibility or can make parsing setup clearer for some developers.
Compatibility Note: The @vonage/server-sdk requires Node.js 18 LTS or higher. Express.js 4.x is compatible with all LTS Node.js versions.
Why Vonage Verify API?
Instead of manually generating codes, managing delivery attempts, handling expiry, and implementing complex verification logic, the Vonage Verify API encapsulates this entire workflow. It provides:
- Secure code generation.
- Multi-channel delivery attempts (SMS primary, often with voice fallback).
- Built-in code expiry and retry logic.
- Simplified verification checks via API calls.
- Protection against certain types of abuse.
- Rate limiting built into the API (maximum 30 requests per second – source).
This significantly reduces development time and potential security pitfalls compared to building an OTP system from scratch.
System Architecture:
Diagram Description:
- The user initiates the 2FA process (e.g., after login) by providing their phone number to the Node.js application via an HTTPS API call.
- The Node.js application calls the Vonage Verify API's
startendpoint with the user's number. - Vonage generates an OTP and sends it via SMS to the user's phone. Vonage returns a
request_idto the Node.js application. - The Node.js application prompts the user (via the frontend) to enter the OTP code they received.
- The user submits the OTP code and implicitly identifies the verification attempt (e.g., via their session or phone number) back to the Node.js application via another HTTPS API call.
- The Node.js application retrieves the relevant
request_idand calls the Vonage Verify API'scheckendpoint with therequest_idand the submitted code. - Vonage verifies the code and returns the result (success or failure) to the Node.js application.
- The Node.js application informs the user of the outcome.
Security Note: All communication between the client and server must use HTTPS/TLS to prevent man-in-the-middle (MITM) attacks that could intercept OTP codes or session tokens. Communication between your server and Vonage API is automatically secured via HTTPS.
Prerequisites:
- Node.js and npm (or yarn): Version 18 LTS or higher installed on your development machine. (Download Node.js)
- Vonage API Account: A free account is sufficient to start. (Sign up for Vonage)
- You'll need your API Key and API Secret found on the Vonage API Dashboard homepage after signing in.
- New accounts receive €2 free credit for testing (verify on dashboard).
- Test Phone Number: Access to a real phone number that can receive SMS messages for testing. Virtual numbers and VOIP services may not work reliably.
- (Optional) Basic understanding of Express.js: Familiarity with routing and middleware helps.
- (Optional)
curlor Postman: For testing the API endpoints.
1. Setting Up the Project
Create the basic project structure and install the necessary dependencies.
1.1 Create Project Directory:
Open your terminal or command prompt and create a new directory for your project.
mkdir vonage-otp-guide
cd vonage-otp-guide1.2 Initialize Node.js Project:
Initialize the project using npm. The -y flag accepts the default settings.
npm init -yThis creates a package.json file.
1.3 Install Dependencies:
Install Express, the Vonage Server SDK, dotenv for environment variables, and body-parser.
npm install express @vonage/server-sdk dotenv body-parserOptional but Recommended for Production:
npm install express-rate-limit libphonenumber-jsexpress: The web framework.@vonage/server-sdk: To interact with the Vonage APIs (latest version recommended).dotenv: To manage sensitive credentials like API keys.body-parser: Middleware to parse JSON request bodies.express-rate-limit: Middleware for rate limiting to prevent abuse (npm package).libphonenumber-js: Library for phone number validation and formatting (npm package).
1.4 Create Project Structure:
Create the following basic file structure:
vonage-otp-guide/
├── node_modules/
├── .env # For environment variables (API keys) – DO NOT COMMIT TO GIT
├── .gitignore # To exclude node_modules and .env from Git
├── index.js # Main application file
├── package.json
└── package-lock.json
1.5 Configure .gitignore:
Create a .gitignore file in the root directory and add the following lines to prevent committing sensitive information and unnecessary files:
# .gitignore
node_modules
.env
*.log1.6 Set Up Environment Variables (.env):
Create a file named .env in the root directory. Find your API Key and API Secret on the Vonage API Dashboard.
# .env
VONAGE_API_KEY=YOUR_API_KEY_HERE
VONAGE_API_SECRET=YOUR_API_SECRET_HERE
# Optional: Set a default port
PORT=3000Replace YOUR_API_KEY_HERE and YOUR_API_SECRET_HERE with your actual credentials from the Vonage dashboard.
Vonage Free Tier: New Vonage accounts receive €2 free credit. Each verification attempt costs approximately €0.05-0.15 depending on destination country, allowing 13-40 test verifications. Check current pricing at Vonage Pricing.
Purpose: Using environment variables is crucial for security. It prevents hardcoding sensitive credentials directly into your source code. dotenv loads these variables into process.env when the application starts.
2. Implementing Core Functionality (Vonage Verify API)
Now, write the core logic in index.js to interact with the Vonage Verify API.
2.1 Initialize Express and Vonage SDK:
Open index.js and add the following setup code:
// index.js
require('dotenv').config(); // Load environment variables from .env file
const express = require('express');
const bodyParser = require('body-parser');
const { Vonage } = require('@vonage/server-sdk');
const rateLimit = require('express-rate-limit');
const { parsePhoneNumber, isValidPhoneNumber } = require('libphonenumber-js');
// --- Configuration ---
const app = express();
const port = process.env.PORT || 3000; // Use port from .env or default to 3000
// --- Vonage Client Initialization ---
// IMPORTANT: Ensure VONAGE_API_KEY and VONAGE_API_SECRET are set in your .env file
if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) {
console.error('⚠️ Error: VONAGE_API_KEY or VONAGE_API_SECRET not found in .env file.');
console.error('Add them to your .env file.');
process.exit(1); // Exit if keys are missing
}
const vonage = new Vonage({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET
});
// --- Rate Limiting Configuration ---
// Prevent abuse of OTP requests – max 5 requests per 15 minutes per IP
const otpRequestLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Limit each IP to 5 requests per windowMs
message: { error: 'Too many OTP requests from this IP. Try again after 15 minutes.' },
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
});
// Verification check rate limiter – max 10 attempts per 5 minutes per IP
const otpCheckLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes
max: 10, // Allow up to 10 check attempts per windowMs (multiple users per IP)
message: { error: 'Too many verification attempts. Try again later.' },
standardHeaders: true,
legacyHeaders: false,
});
// --- Middleware ---
// Parse JSON bodies (as sent by API clients)
app.use(bodyParser.json());
// Parse URL-encoded bodies (as sent by HTML forms)
app.use(bodyParser.urlencoded({ extended: true }));
// In-memory storage for demo purposes.
// Production apps should use a database or persistent cache (e.g., Redis).
// Structure: { phoneNumber: { requestId: string, attempts: number, lastAttempt: Date } }
const verificationRequests = {};
// --- Helper Functions ---
/**
* Validates and formats phone number to E.164 format
* @param {string} phoneNumber – Raw phone number input
* @returns {object} – { valid: boolean, formatted: string, error: string }
*/
function validateAndFormatPhone(phoneNumber) {
try {
// Check if it's a valid phone number
if (!isValidPhoneNumber(phoneNumber)) {
return { valid: false, error: 'Invalid phone number format' };
}
// Parse and format to E.164
const parsed = parsePhoneNumber(phoneNumber);
return {
valid: true,
formatted: parsed.format('E.164') // e.g., +14155552671
};
} catch (error) {
return {
valid: false,
error: 'Unable to parse phone number. Use international format (e.g., +14155552671).'
};
}
}
// --- Routes (will be added below) ---
// ...
// --- Health Check Endpoint ---
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok', service: 'vonage-otp-api' });
});
// --- Start Server ---
app.listen(port, () => {
console.log(`✅ Server running on http://localhost:${port}`);
console.log(`📋 Health check: http://localhost:${port}/health`);
});
// --- Graceful Shutdown ---
process.on('SIGTERM', () => {
console.log('SIGTERM signal received: closing HTTP server');
server.close(() => {
console.log('HTTP server closed');
process.exit(0);
});
});Explanation:
require('dotenv').config();: Loads variables from the.envfile. Call this early in your application.- Imports
express,body-parser, and theVonageclass from the SDK, plus rate limiting and phone validation libraries. - Initializes the
expressapp and sets theport. - Crucially, initializes the
Vonageclient using the API key and secret loaded fromprocess.env. Includes a check to ensure the keys are present. - Configures rate limiting middleware to prevent abuse: 5 OTP requests per 15 minutes per IP for
/request-otp, and 10 verification attempts per 5 minutes per IP for/check-otp(OWASP recommendation). - Sets up
bodyParsermiddleware to correctly parse incoming JSON and URL-encoded request bodies. verificationRequests: A simple JavaScript object used as temporary storage to map phone numbers to therequest_idreturned by Vonage. Note: This is for demonstration only. In production, use Redis or a database.- Adds a
validateAndFormatPhone()helper function usinglibphonenumber-jsto validate and convert phone numbers to E.164 format, which Vonage requires. - Includes
/healthendpoint for monitoring and load balancer health checks. - Implements graceful shutdown handling for production deployments.
2.2 Implement OTP Request Route (/request-otp):
Add the following route handler in index.js within the // --- Routes --- section:
// index.js (continued)
// --- Routes ---
/**
* @route POST /request-otp
* @description Initiates an OTP verification request via SMS.
* @param {string} phoneNumber – The user's phone number in E.164 format (e.g., +14155552671).
* @returns {object} 200 – Success message (OTP sent).
* @returns {object} 400 – Bad request (e.g., missing phone number).
* @returns {object} 429 – Too many requests (rate limit exceeded).
* @returns {object} 500 – Server error or Vonage API error.
*/
app.post('/request-otp', otpRequestLimiter, async (req, res) => {
const { phoneNumber } = req.body;
// Input validation
if (!phoneNumber) {
return res.status(400).json({ error: 'Phone number is required.' });
}
// Validate and format phone number to E.164
const phoneValidation = validateAndFormatPhone(phoneNumber);
if (!phoneValidation.valid) {
return res.status(400).json({ error: phoneValidation.error });
}
const formattedNumber = phoneValidation.formatted;
console.log(`📱 Requesting OTP for: ${formattedNumber}`);
// Check for duplicate concurrent requests
if (verificationRequests[formattedNumber]) {
const existingRequest = verificationRequests[formattedNumber];
const timeSinceLastRequest = Date.now() - existingRequest.lastAttempt.getTime();
// Prevent requests within 60 seconds of previous request
if (timeSinceLastRequest < 60000) {
return res.status(429).json({
error: 'Wait before requesting a new code.',
retryAfter: Math.ceil((60000 - timeSinceLastRequest) / 1000)
});
}
}
try {
const result = await vonage.verify.start({
number: formattedNumber,
brand: 'YourAppName', // IMPORTANT: Customize this! Appears in the SMS message.
code_length: 6, // 6-digit code (options: 4 or 6)
workflow_id: 6 // SMS only, no voice fallback (see workflow section)
// See Vonage Docs: https://developer.vonage.com/api/verify#startVerification
});
if (result.status === '0') { // Status '0' indicates success
// Store the request_id temporarily, associated with the phone number
verificationRequests[formattedNumber] = {
requestId: result.request_id,
attempts: 0,
lastAttempt: new Date()
};
console.log(`✅ OTP request successful. Request ID: ${result.request_id}`);
// IMPORTANT: Do NOT send the request_id to the client-side
res.status(200).json({
message: 'OTP requested successfully. Check your phone.',
expiresIn: 300 // Code expires in 300 seconds (5 minutes)
});
} else {
// Handle Vonage API errors
console.error(`❌ Vonage Verify API Error: Status ${result.status} – ${result.error_text}`);
// Map specific Vonage status codes to user-friendly messages
const errorMessages = {
'1': 'Service temporarily unavailable. Try again.',
'3': 'Invalid phone number format.',
'4': 'Authentication failed. Contact support.',
'7': 'This number is blocked from verification.',
'9': 'Verification quota exceeded. Contact support.',
'10': 'A verification request is already in progress for this number.',
'15': 'This destination is not supported.'
};
const userMessage = errorMessages[result.status] || `Failed to request OTP: ${result.error_text}`;
res.status(result.status === '9' ? 402 : 500).json({ error: userMessage });
}
} catch (error) {
// Handle errors thrown by the SDK or network issues
console.error('❌ Server Error requesting OTP:', error);
res.status(500).json({ error: 'An unexpected server error occurred while requesting the OTP.' });
}
});
// ... (other routes and server start)Key Improvements:
- Phone Number Validation: Uses
libphonenumber-jsto validate and format numbers to E.164 before sending to Vonage API. - Rate Limiting: Applied via middleware to prevent abuse (5 requests per 15 minutes per IP).
- Duplicate Request Prevention: Checks if a recent request exists and enforces 60-second cooldown.
- Enhanced Error Handling: Maps Vonage error codes to user-friendly messages based on official documentation.
- Security: Never exposes
request_idto the client. - Workflow Configuration: Uses
workflow_id: 6(SMS only) to avoid unexpected voice calls. Default workflow (1) includes voice fallback after 125 seconds (source).
Vonage Verify Status Codes Reference:
| Code | Meaning | Action |
|---|---|---|
| 0 | Success | OTP sent successfully |
| 1 | Throttled | Exceeded 30 requests/second limit |
| 3 | Invalid parameter | Phone number format invalid |
| 4 | Invalid credentials | API key/secret incorrect |
| 6 | Unable to route | Number unreachable |
| 7 | Number blacklisted | Anti-fraud system blocked |
| 9 | Partner quota exceeded | Insufficient credit |
| 10 | Concurrent verification | Duplicate request in progress |
| 15 | Unsupported network | Destination not supported |
Source: Vonage Verify API Status Codes
2.3 Implement OTP Check Route (/check-otp):
Add the route handler for verifying the code submitted by the user:
// index.js (continued)
/**
* @route POST /check-otp
* @description Verifies the OTP code submitted by the user.
* @param {string} phoneNumber – The user's phone number (used to retrieve request_id).
* @param {string} code – The OTP code entered by the user.
* @returns {object} 200 – Verification successful.
* @returns {object} 400 – Bad request (e.g., missing fields, invalid code format).
* @returns {object} 401 – Unauthorized (verification failed – wrong code, expired, etc.).
* @returns {object} 404 – Not Found (request ID not found for the phone number).
* @returns {object} 429 – Too many verification attempts.
* @returns {object} 500 – Server error or Vonage API error.
*/
app.post('/check-otp', otpCheckLimiter, async (req, res) => {
const { phoneNumber, code } = req.body;
// Input validation
if (!phoneNumber || !code) {
return res.status(400).json({ error: 'Phone number and code are required.' });
}
// Validate code format (4 or 6 digits)
if (!/^[0-9]{4,6}$/.test(code)) {
return res.status(400).json({ error: 'Code must be 4-6 digits.' });
}
// Format phone number
const phoneValidation = validateAndFormatPhone(phoneNumber);
if (!phoneValidation.valid) {
return res.status(400).json({ error: phoneValidation.error });
}
const formattedNumber = phoneValidation.formatted;
// Retrieve the verification request
const verificationData = verificationRequests[formattedNumber];
if (!verificationData) {
console.warn(`⚠️ No pending verification found for: ${formattedNumber}`);
return res.status(404).json({
error: 'No pending verification found for this number, or it has expired. Request a new code.'
});
}
// Check attempt limit (Vonage allows 3 attempts per request)
if (verificationData.attempts >= 3) {
delete verificationRequests[formattedNumber];
return res.status(429).json({
error: 'Too many failed attempts. Request a new code.'
});
}
console.log(`🔍 Verifying OTP for: ${formattedNumber} with code: ${code} (Request ID: ${verificationData.requestId})`);
try {
const result = await vonage.verify.check(verificationData.requestId, code);
if (result.status === '0') { // Status '0' indicates successful verification
console.log(`✅ Verification successful for: ${formattedNumber}`);
// Verification successful – clear the stored request ID
delete verificationRequests[formattedNumber];
// In production: Update user status to verified, grant access, etc.
res.status(200).json({
message: 'Verification successful!',
verified: true
});
} else {
// Increment attempt counter
verificationRequests[formattedNumber].attempts += 1;
// Handle Vonage verification failures
console.warn(`⚠️ Verification failed for ${formattedNumber}: Status ${result.status} – ${result.error_text}`);
// Map error codes to user-friendly messages
const errorMessages = {
'16': 'Code has expired. Request a new code.',
'17': 'Too many incorrect attempts. Request a new code.',
'6': 'Invalid code. Try again.',
'101': 'No matching verification request found.'
};
const userMessage = errorMessages[result.status] || `Verification failed: ${result.error_text}`;
const remainingAttempts = 3 - verificationRequests[formattedNumber].attempts;
// Clear request if expired or too many attempts
if (['16', '17', '101'].includes(result.status)) {
delete verificationRequests[formattedNumber];
return res.status(401).json({ error: userMessage });
}
res.status(401).json({
error: userMessage,
remainingAttempts: remainingAttempts
});
}
} catch (error) {
// Handle errors thrown by the SDK
console.error('❌ Server Error checking OTP:', error.message || error);
// Check for structured error from Vonage SDK
if (error.body && error.body.error_text) {
console.error(`❌ Vonage Check API Error Detail: ${error.statusCode} – ${error.body.error_text}`);
res.status(error.statusCode || 500).json({
error: `Verification check failed: ${error.body.error_text}`
});
} else {
// Generic server error
res.status(500).json({ error: 'An unexpected server error occurred during verification.' });
}
}
});
// ... (server start)Key Improvements:
- Input Validation: Validates code format (4-6 digits) before API call.
- Attempt Tracking: Tracks verification attempts and enforces Vonage's 3-attempt limit per request.
- Remaining Attempts: Informs user of remaining attempts before lockout.
- Enhanced Error Mapping: Maps Vonage error codes to clear user messages.
- Automatic Cleanup: Removes expired or exhausted verification requests.
Vonage Verify Check Status Codes:
| Code | Meaning | User-Facing Action |
|---|---|---|
| 0 | Verification succeeded | Grant access |
| 6 | Wrong code submitted | Allow retry (up to 3 total) |
| 16 | Code expired | Request new code |
| 17 | Too many attempts | Request new code |
| 101 | Request ID not found | Request new code |
Source: Vonage Verify API Status Codes
3. Building a Complete API Layer
You've now defined the core API endpoints with security enhancements:
GET /health: Health check endpoint for monitoring.POST /request-otp: Initiates the verification process (rate limited: 5 per 15 min per IP).POST /check-otp: Verifies the submitted code (rate limited: 10 per 5 min per IP).
Vonage Verify Workflow Configuration:
The Vonage Verify API supports 7 predefined workflows for delivering OTP codes (source):
| workflow_id | Delivery Sequence | Default Timings | Use Case |
|---|---|---|---|
| 1 (default) | SMS → TTS → TTS | 125s → 180s → 300s | Maximum deliverability |
| 2 | SMS → SMS → TTS | 125s → 180s → 300s | SMS preference with voice backup |
| 3 | TTS → TTS | 150s → 150s | Voice-only (accessibility) |
| 4 | SMS → SMS | 120s → 180s | SMS-only with retry |
| 5 | SMS → TTS | 120s → 180s | Single SMS then voice |
| 6 | SMS only | Expires after 300s | Recommended: SMS-only, no voice |
| 7 | TTS only | Expires after 300s | Voice-only |
Recommendation: Use workflow_id: 6 (SMS-only) to avoid unexpected voice calls and associated costs. The default workflow (1) will attempt voice calls after 125 seconds if the code isn't verified.
Security Considerations – HTTPS Requirement:
All production deployments must use HTTPS to prevent:
- Man-in-the-middle (MITM) attacks intercepting OTP codes
- Session hijacking
- Credential theft
Use services like Let's Encrypt for free SSL/TLS certificates, or deploy behind a reverse proxy (nginx, Cloudflare) that handles TLS termination.
API Documentation & Testing Examples:
Here are examples using curl to test the endpoints. Replace placeholders accordingly.
3.1 Test Health Check:
curl http://localhost:3000/healthExpected Response:
{
"status": "ok",
"service": "vonage-otp-api"
}3.2 Test Requesting an OTP:
(Replace +14155552671 with a real phone number you can receive SMS on, in E.164 format.)
curl -X POST http://localhost:3000/request-otp \
-H "Content-Type: application/json" \
-d '{"phoneNumber": "+14155552671"}'Expected Success Response (200 OK):
{
"message": "OTP requested successfully. Check your phone.",
"expiresIn": 300
}(You should receive an SMS on the target phone within seconds.)
Example Error Response (400 Bad Request – Invalid format):
curl -X POST http://localhost:3000/request-otp \
-H "Content-Type: application/json" \
-d '{"phoneNumber": "555-1234"}'{
"error": "Invalid phone number format"
}3.3 Test Checking an OTP:
(Use the same phone number as above and the code received via SMS.)
curl -X POST http://localhost:3000/check-otp \
-H "Content-Type: application/json" \
-d '{"phoneNumber": "+14155552671", "code": "123456"}' # Replace with actual codeExpected Success Response (200 OK):
{
"message": "Verification successful!",
"verified": true
}Example Error Response (401 Unauthorized – Wrong Code):
{
"error": "Invalid code. Try again.",
"remainingAttempts": 2
}Example Error Response (429 Too Many Requests):
{
"error": "Too many OTP requests from this IP. Try again after 15 minutes."
}4. Security Considerations
Critical Security Requirements:
-
HTTPS Only: All production deployments must use HTTPS. Never transmit OTP codes or credentials over HTTP.
-
Rate Limiting: Implemented via
express-rate-limit:/request-otp: Max 5 requests per 15 minutes per IP/check-otp: Max 10 attempts per 5 minutes per IP- Prevents brute-force attacks and SMS spam abuse
-
Input Validation & Sanitization:
- Phone numbers validated with
libphonenumber-jsbefore API calls - OTP codes validated for format (4-6 digits, numeric only)
- Prevents SQL injection, XSS, and other injection attacks
- Phone numbers validated with
-
Attempt Limiting:
- Maximum 3 verification attempts per OTP request (enforced by Vonage)
- Application-level tracking prevents excessive attempts
- Failed requests automatically cleared after limit
-
Session Management: (Production requirement)
- Associate
request_idwith authenticated user session, not just phone number - Use secure session cookies with
httpOnly,secure, andsameSiteflags - Implement CSRF protection for state-changing operations
- Example with
express-session:
javascriptconst session = require('express-session'); app.use(session({ secret: process.env.SESSION_SECRET, // Strong random secret resave: false, saveUninitialized: false, cookie: { secure: true, // HTTPS only httpOnly: true, // Prevent XSS access sameSite: 'strict', // CSRF protection maxAge: 3600000 // 1 hour } })); - Associate
-
Data Privacy & GDPR Compliance:
- Phone numbers are Personally Identifiable Information (PII)
- When logging, hash or mask phone numbers:
+1415555**** - Implement data retention policies (delete verification records after 24 hours)
- Provide user data deletion mechanisms
- Document data processing in privacy policy
-
Prevent Replay Attacks:
- Each
request_idcan only be verified once (Vonage enforces) - Codes expire after 300 seconds (5 minutes)
- Application clears verification data after successful check
- Each
-
Secret Management:
- Never commit
.envfiles to version control - Use secret management services in production (AWS Secrets Manager, HashiCorp Vault)
- Rotate API credentials periodically (quarterly recommended)
- Use separate credentials for development, staging, and production
- Never commit
OWASP Recommendations for MFA:
- Require MFA for all privileged accounts (source)
- SMS MFA is acceptable but not recommended for high-value targets due to SIM swap vulnerabilities
- Consider TOTP (Google Authenticator, Authy) or FIDO2/WebAuthn for higher security
- Implement secure MFA reset procedures (don't weaken security with weak recovery)
5. Database Schema and Data Layer (Production)
For production, replace the in-memory verificationRequests object with a persistent store.
Option 1: Redis (Recommended for OTP storage)
Redis provides fast key-value storage with built-in expiration, ideal for temporary verification data.
const redis = require('redis');
const client = redis.createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379'
});
await client.connect();
// Store verification request
await client.setEx(
`verify:${userId}`, // Key: verify:<user_id>
600, // TTL: 10 minutes
JSON.stringify({
requestId: result.request_id,
phoneNumber: formattedNumber,
attempts: 0,
createdAt: new Date().toISOString()
})
);
// Retrieve verification request
const verifyData = await client.get(`verify:${userId}`);
const data = JSON.parse(verifyData);
// Delete after successful verification
await client.del(`verify:${userId}`);Option 2: PostgreSQL Database Schema
For applications with existing user databases:
-- Users table with 2FA fields
CREATE TABLE users (
user_id SERIAL PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
phone_number VARCHAR(20), -- Verified phone in E.164
phone_verified BOOLEAN DEFAULT FALSE,
two_factor_enabled BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Separate table for verification requests
CREATE TABLE verification_requests (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(user_id) ON DELETE CASCADE,
request_id VARCHAR(50) NOT NULL, -- Vonage request_id
phone_number VARCHAR(20) NOT NULL,
attempts INTEGER DEFAULT 0,
status VARCHAR(20) DEFAULT 'pending', -- pending, verified, expired, failed
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
verified_at TIMESTAMPTZ,
-- Performance indexes
INDEX idx_user_id (user_id),
INDEX idx_request_id (request_id),
INDEX idx_expires_at (expires_at)
);
-- Auto-delete expired records (PostgreSQL with pg_cron extension)
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'delete-expired-verifications',
'0 * * * *', -- Every hour
'DELETE FROM verification_requests WHERE expires_at < NOW()'
);Integration Example with Database:
// Using pg (PostgreSQL client)
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Store verification request
app.post('/request-otp', otpRequestLimiter, async (req, res) => {
// ... validation code ...
const result = await vonage.verify.start({ /* ... */ });
if (result.status === '0') {
// Store in database instead of memory
await pool.query(
`INSERT INTO verification_requests
(user_id, request_id, phone_number, expires_at)
VALUES ($1, $2, $3, NOW() + INTERVAL '10 minutes')`,
[req.session.userId, result.request_id, formattedNumber]
);
res.status(200).json({ message: 'OTP sent successfully' });
}
});
// Verify OTP
app.post('/check-otp', otpCheckLimiter, async (req, res) => {
// ... validation code ...
// Retrieve from database
const { rows } = await pool.query(
`SELECT request_id, attempts, expires_at
FROM verification_requests
WHERE user_id = $1 AND status = 'pending'
ORDER BY created_at DESC LIMIT 1`,
[req.session.userId]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'No pending verification' });
}
const verifyData = rows[0];
// Check expiration
if (new Date() > new Date(verifyData.expires_at)) {
await pool.query(
`UPDATE verification_requests SET status = 'expired' WHERE user_id = $1`,
[req.session.userId]
);
return res.status(401).json({ error: 'Code expired' });
}
// Check attempts
if (verifyData.attempts >= 3) {
await pool.query(
`UPDATE verification_requests SET status = 'failed' WHERE user_id = $1`,
[req.session.userId]
);
return res.status(429).json({ error: 'Too many attempts' });
}
// Verify with Vonage
const result = await vonage.verify.check(verifyData.request_id, code);
if (result.status === '0') {
// Success – update database
await pool.query(
`UPDATE verification_requests
SET status = 'verified', verified_at = NOW()
WHERE user_id = $1`,
[req.session.userId]
);
// Update user's phone_verified status
await pool.query(
`UPDATE users SET phone_verified = TRUE, two_factor_enabled = TRUE
WHERE user_id = $1`,
[req.session.userId]
);
res.status(200).json({ message: 'Verification successful' });
} else {
// Increment attempts
await pool.query(
`UPDATE verification_requests
SET attempts = attempts + 1
WHERE user_id = $1`,
[req.session.userId]
);
res.status(401).json({ error: 'Invalid code', remainingAttempts: 3 - (verifyData.attempts + 1) });
}
});6. Troubleshooting and Common Issues
Issue: SMS Not Received
Possible causes:
- Invalid phone number format: Ensure E.164 format (+countrycode + number, no spaces).
- Carrier blocking: Some carriers block short-code SMS. Try a different number.
- Vonage credit exhausted: Check balance at dashboard.
- Network delay: SMS can take 5-30 seconds. Wait before retrying.
- Number type restrictions: VOIP numbers (Google Voice, Skype) may not receive SMS.
Issue: "Throttled" Error (Status 1)
Cause: Exceeding 30 requests/second to Vonage API (source).
Solution: Implement request queuing or reduce request rate.
Issue: "Invalid credentials" (Status 4)
Causes:
- Wrong
VONAGE_API_KEYorVONAGE_API_SECRETin.env - Credentials not loaded (check
dotenv.config()is called first) - Using test credentials in production
Solution: Verify credentials on Vonage Dashboard.
Issue: Code Expired (Status 16)
Default expiration: 300 seconds (5 minutes) for workflow 6.
Solution: Request a new code. Consider increasing pin_expiry parameter up to 3600s (1 hour).
Issue: Rate Limit Errors (429)
Cause: Application-level rate limiting triggered (5 requests per 15 min).
Solution: Wait for the cooldown period or adjust rate limit configuration for your use case.
International SMS Delivery Challenges:
- India: Requires sender ID registration with TRAI
- Saudi Arabia: Blocked numbers cannot receive OTP
- China: SMS delivery highly restricted, use alternative channels
- USA: 10DLC registration required for application-to-person (A2P) messaging
Consult Vonage country-specific guides for requirements.
Debugging Tips:
-
Enable detailed logging:
javascriptconsole.log('Vonage request:', JSON.stringify({ number, brand, workflow_id })); console.log('Vonage response:', JSON.stringify(result)); -
Test with a known working number first.
-
Check Vonage API logs on the dashboard for detailed error messages.
-
Verify environment variables are loaded:
javascriptconsole.log('API Key loaded:', !!process.env.VONAGE_API_KEY);
7. Next Steps and Production Deployment
Production Readiness Checklist:
- Replace in-memory storage with Redis or database
- Implement proper session management with authenticated users
- Deploy behind HTTPS (Let's Encrypt, Cloudflare, AWS ALB)
- Configure CORS for frontend domain
- Set up monitoring (Datadog, New Relic, Prometheus)
- Implement structured logging (Winston, Pino)
- Add health check endpoints for load balancers
- Configure graceful shutdown handlers
- Set up automated backups for verification audit logs
- Document API with OpenAPI/Swagger spec
- Implement API versioning (/v1/request-otp)
- Add request correlation IDs for debugging
- Configure firewall rules and DDoS protection
- Set up secret rotation procedures
- Implement user notification system (email confirmations)
- Add analytics and abuse detection
- Create runbooks for common issues
- Set up CI/CD pipeline with automated testing
Docker Deployment Example:
# Dockerfile
FROM node:18-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy application code
COPY . .
# Run as non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
USER nodejs
EXPOSE 3000
# Health check
HEALTHCHECK \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
CMD ["node", "index.js"]Docker Compose with Redis:
version: '3.8'
services:
api:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- VONAGE_API_KEY=${VONAGE_API_KEY}
- VONAGE_API_SECRET=${VONAGE_API_SECRET}
- REDIS_URL=redis://redis:6379
depends_on:
- redis
restart: unless-stopped
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis-data:/data
restart: unless-stopped
volumes:
redis-data:Advanced Features to Consider:
- Backup Codes: Generate one-time recovery codes when enabling 2FA.
- Remember Device: Skip 2FA for trusted devices (30-day cookie).
- Multi-Channel Support: Add email, WhatsApp, or authenticator app options.
- TOTP Migration: Offer upgrade path to TOTP/Google Authenticator.
- WebAuthn/FIDO2: Implement passwordless authentication with hardware keys.
- Adaptive Auth: Risk-based authentication (skip 2FA for low-risk scenarios).
- Fraud Detection: Monitor patterns for suspicious activity.
- Compliance: SOC 2, GDPR, HIPAA audit trails.
Additional Resources:
- Vonage Verify API Documentation
- OWASP Authentication Cheat Sheet
- OWASP MFA Cheat Sheet
- Node.js Security Best Practices
- Express Security Best Practices
- NIST Digital Identity Guidelines (SP 800-63B)
Conclusion:
This guide provides a comprehensive implementation of SMS-based OTP 2FA using Node.js, Express, and the Vonage Verify API. The implementation includes essential security features like rate limiting, input validation, and proper error handling. For production use, migrate from in-memory storage to Redis or a database, implement proper user session management, deploy over HTTPS, and consider the advanced security features discussed.
While SMS 2FA is a significant security improvement over password-only authentication, consider offering users the option to upgrade to TOTP-based authenticators or hardware security keys for enhanced protection against SIM-swap and phishing attacks.
Frequently Asked Questions
How to implement 2FA with Vonage Verify API?
Integrate the Vonage Verify API into your Node.js/Express app to implement two-factor authentication. This involves setting up API endpoints to request an OTP, which is sent to the user's phone via SMS, and then verifying the OTP submitted by the user. The Vonage API handles code generation, delivery, and verification.
What is the Vonage Verify API used for?
The Vonage Verify API simplifies the implementation of two-factor authentication (2FA) by generating, delivering (via SMS and potentially voice), and verifying one-time passwords (OTPs). It streamlines the process and reduces development time compared to building an OTP system from scratch.
Why use Vonage Verify API for OTP?
Vonage Verify API handles secure code generation, multi-channel delivery, retries, code expiry, and verification, reducing development time and potential security risks. It simplifies a complex process into easy API calls, offering a more robust solution than building it yourself.
When should I use body-parser middleware?
While Express 4.16+ has built-in body parsing middleware, explicitly using `body-parser` can improve clarity or ensure compatibility, especially in projects using older Express versions or when more explicit parsing configuration is needed.
Can I customize the SMS sender name with Vonage?
Yes, you can customize the sender name (brand) that appears in the SMS message sent by the Vonage Verify API. When calling `vonage.verify.start()`, set the `brand` parameter to your application's name. This clearly identifies the source of the OTP to the user.
How to request an OTP with Node.js and Vonage?
Make a POST request to the `/request-otp` endpoint of your Node.js application, providing the user's phone number in E.164 format (e.g., +14155552671) in the request body. The server will then interact with the Vonage Verify API to initiate the OTP process.
How to verify OTP code from Vonage?
Send a POST request to your server's `/check-otp` endpoint including both the phone number and the received OTP code. The backend will compare this code against the Vonage Verify API using the associated request ID. Never expose the `request_id` directly to the client for security best practices.
What are Vonage API key and secret?
The Vonage API Key and Secret are your credentials to access the Vonage APIs, including the Verify API. Find them on your Vonage API Dashboard. Store them securely, typically in a `.env` file, never directly in your code.
How to handle Vonage Verify API errors?
The Vonage Verify API returns a status code in its responses. '0' indicates success. Non-zero statuses represent specific errors. Check the `result.status` and `result.error_text` to identify the cause of the failure and handle appropriately.
What is the system architecture for Vonage OTP?
The user sends their phone number; the Node.js app requests an OTP from Vonage; Vonage sends an SMS to the user; the user submits the code; the app verifies the code with Vonage and sends back success/failure.
Why is persistent data storage recommended for OTP?
Using a database or cache ensures data isn't lost if the server restarts, making the system scalable. It also allows secure linking between verification requests and individual user sessions.
What database schema is suitable for Vonage OTP?
Add columns to your `Users` table to store the Vonage `request_id` and an optional expiry timestamp. Alternatively, use a separate table linked to `user_id` to store pending verification details.
What fallback mechanism is used by Vonage Verify?
If the initial SMS fails, Vonage will fallback to a text-to-speech (TTS) phone call that reads out the code. You can also configure custom workflows for different fallback strategies.