Implement Node.js Express OTP/2FA SMS with Vonage Verify API
A guide on implementing SMS-based OTP/2FA in a Node.js Express application using the Vonage Verify API, covering setup, API interaction, and error handling.
This guide provides a complete walkthrough for implementing One-Time Password (OTP) or Two-Factor Authentication (2FA) via SMS in a Node.js application using Express and the Vonage Verify API. We will build a simple web application that requests a user's phone number, sends a verification code via SMS using Vonage, and then verifies the code entered by the user.
This approach simplifies the complexities of OTP generation, delivery retries (including voice call fallbacks), code expiry, and verification checks by leveraging the robust features of the Vonage Verify API.
Project Overview and Goals
What We're Building:
A Node.js web application using the Express framework.
A simple frontend with three stages:
Enter phone number.
Enter the received OTP code.
Success/Failure indication.
Backend logic to interact with the Vonage Verify API for sending and checking OTP codes via SMS.
Problem Solved: Securely verifying user identity or actions by confirming possession of a specific phone number, a common requirement for sign-ups, logins, or sensitive transactions.
Technologies Used:
Node.js: JavaScript runtime environment.
Express: Minimalist web framework for Node.js.
Vonage Server SDK for Node.js (@vonage/server-sdk): Official library to interact with Vonage APIs.
Vonage Verify API: Handles the OTP lifecycle (generation, sending via SMS/voice, checking).
EJS: Templating engine for rendering simple HTML views.
dotenv: Module to load environment variables from a .env file.
body-parser: Middleware to parse incoming request bodies.
express-validator: Middleware for input validation.
Architecture:
The flow is straightforward:
User: Accesses the web application via a browser.
Express App (Node.js):
Serves the HTML pages (EJS templates).
Receives user input (phone number, OTP code).
Communicates with the Vonage Verify API using the Vonage SDK.
Renders appropriate responses based on API results.
Vonage Verify API:
Receives requests from the Express app.
Generates and sends OTP codes via SMS (or voice fallback).
Expected Outcome: A functional web application demonstrating the OTP/2FA flow using SMS verification powered by Vonage.
1. Setting up the Project
Let's initialize our Node.js project and install the necessary dependencies.
Create Project Directory:
Open your terminal or command prompt and create a new directory for your project, then navigate into it:
Bash
mkdir vonage-otp-express
cd vonage-otp-express
Initialize npm:
Create a package.json file to manage project dependencies and scripts:
Bash
npm init -y
Install Dependencies:
Install Express, the Vonage SDK, EJS for templating, dotenv for environment variables, body-parser for handling form data, and express-validator for input validation:
dotenv: Loads environment variables from a .env file into process.env. Crucial for keeping secrets out of code.
ejs: Simple templating engine for rendering HTML views with dynamic data.
body-parser: Middleware needed to parse JSON and URL-encoded request bodies (like form submissions).
express-validator: Middleware for validating incoming request data (like phone numbers and codes).
Create Project Structure:
Set up a basic structure for organization:
Bash
mkdir views public
touch index.js .env .gitignore
views/: Directory to store our EJS template files (.ejs).
public/: Directory for static assets like CSS or client-side JavaScript (optional for this guide).
index.js: Main application file where our Express server logic will reside.
.env: File to store sensitive credentials like API keys (will be ignored by Git).
.gitignore: Specifies intentionally untracked files that Git should ignore.
Configure .gitignore:
Add node_modules and .env to your .gitignore file to prevent committing dependencies and secrets:
Text
# .gitignore
node_modules/
.env
Configure Environment Variables (.env):
Open the .env file and add your Vonage API Key and Secret.
How to get Vonage API Key and Secret:
a. Log in to your Vonage API Dashboard.
b. Your API Key and API Secret are displayed prominently on the main dashboard page under "API settings".
c. Copy these values into your .env file.
.env
# .env
VONAGE_API_KEY=YOUR_API_KEY
VONAGE_API_SECRET=YOUR_API_SECRET
VONAGE_BRAND_NAME=YourAppName # Optional: Customize the brand name in the SMS message
Replace YOUR_API_KEY and YOUR_API_SECRET with your actual credentials. VONAGE_BRAND_NAME will be used in the SMS message ("YourAppName code is 1234").
2. Implementing Core Functionality (Verify API Workflow)
Now, let's build the Express application logic and the views for our OTP flow.
Initialize Express and Vonage SDK (index.js):
Open index.js and set up the basic Express app, configure EJS, load environment variables, initialize the Vonage SDK, and require express-validator.
JavaScript
// index.jsrequire('dotenv').config(); // Load .env variables into process.envconst express = require('express');
const bodyParser = require('body-parser');
const { Vonage } = require('@vonage/server-sdk');
const { check, validationResult } = require('express-validator'); // For input validationconst app = express();
const port = process.env.PORT || 3000;
// Initialize Vonage SDK// IMPORTANT: Ensure VONGAGE_API_KEY and VONAGE_API_SECRET are in your .env fileif (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) {
console.error('Error: VONAGE_API_KEY and VONAGE_API_SECRET must be set in .env file.');
process.exit(1); // Exit if keys are missing
}
const vonage = newVonage({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET
});
// Middleware Setup
app.set('view engine', 'ejs'); // Set EJS as the template engine
app.use(bodyParser.json()); // Parse JSON bodies
app.use(bodyParser.urlencoded({ extended: true })); // Parse URL-encoded bodies (form submissions)
app.use(express.static(__dirname + '/public')); // Serve static files (optional)// --- Routes will go here ---// Start the server
app.listen(port, () => {
console.log(`Server listening at http://localhost:${port}`);
});
require('dotenv').config(): Loads variables from .env. Must be called early.
We initialize Vonage with the API key and secret from process.env.
Basic check added to ensure keys are present before proceeding.
app.set('view engine', 'ejs'): Tells Express to use EJS for rendering files from the views directory.
bodyParser middleware is configured to handle form submissions.
express-validator functions (check, validationResult) are imported.
Page 1: Request Verification Code (View and Routes):
Create View (views/index.ejs):
This page will contain the form to enter the phone number. It includes a placeholder for displaying messages and pre-fills the number if an error occurred.
HTML
<!-- views/index.ejs --><!DOCTYPE html><html><head><title>Enter Phone Number</title><!-- Add basic styling if desired --></head><body><h1>Enter Your Phone Number for Verification</h1>
<% if (typeof message !== 'undefined' && message) { %>
<pstyle=""color: <%= messageType === 'error' ? 'red' : 'green' %>;""><%= message %></p>
<% } %>
<formmethod=""POST"" action=""/request-verification""><labelfor=""number"">Phone Number (e.g., 14155552671):</label><br><inputtype=""tel"" id=""number"" name=""number"" requiredplaceholder=""Includecountrycode"" value=""<%= typeofphoneNumber !== 'undefined' ? phoneNumber: '' %>""><br><br><buttontype=""submit"">Send Verification Code</button></form></body></html>
It displays a message if passed from the server (used for success/error feedback).
The form POSTs to the /request-verification endpoint.
The value attribute of the input field is set to phoneNumber if it's passed to the template (useful on error).
We recommend instructing users to include the country code (E.164 format is best for Vonage, e.g., 14155552671 for US).
Create GET Route / (index.js):
This route simply renders the index.ejs view when the user visits the root URL. Add this inside index.js before app.listen():
JavaScript
// index.js (continued)// --- Routes ---
app.get('/', (req, res) => {
// Render the initial page with the phone number form
res.render('index', { message: null, phoneNumber: null }); // Pass null message and number initially
});
// --- More routes below ---
Create POST Route /request-verification (index.js):
This route handles the form submission from index.ejs. It takes the phone number, calls the Vonage Verify API to start the verification process, and then renders the next page (verify.ejs) for code entry. If an error occurs, it re-renders the index.ejs page with the error message and the previously entered phone number.
JavaScript
// index.js (continued)
app.post('/request-verification', async (req, res) => {
const phoneNumber = req.body.number;
const brand = process.env.VONAGE_BRAND_NAME || ""MyApp""; // Use brand from .env or default// Basic validation: Check if number existsif (!phoneNumber) {
return res.render('index', {
message: 'Phone number is required.',
messageType: 'error',
phoneNumber: phoneNumber // Pass back the (empty) number
});
}
console.log(`Requesting verification for number: ${phoneNumber}`);
try {
const response = await vonage.verify.start({
number: phoneNumber,
brand: brand
// workflow_id: 6 // Optional: To use SMS only workflow. Default includes SMS -> TTS -> TTS
});
console.log('Vonage Verify Start Response:', response);
if (response.status === '0') {
// Successfully started verification// Render the page to enter the code, passing the request_id and phone number
res.render('verify', {
requestId: response.request_id,
phoneNumber: phoneNumber, // Pass number for display/resend/cancelmessage: 'Verification code sent. Please check your phone.',
messageType: 'success'
});
} else {
// Handle Vonage API errorsconsole.error('Vonage Verify Start Error:', response.error_text);
res.render('index', {
message: `Error starting verification: ${response.error_text}`,
messageType: 'error',
phoneNumber: phoneNumber // Pass number back to the form
});
}
} catch (error) {
// Handle network or other unexpected errorsconsole.error('Error calling Vonage Verify API:', error);
res.render('index', {
message: 'An unexpected error occurred. Please try again later.',
messageType: 'error',
phoneNumber: phoneNumber // Pass number back to the form
});
}
});
// --- More routes below ---
It retrieves the number from the request body (req.body).
It calls vonage.verify.start().
Success (response.status === '0'): It renders verify.ejs (created next), passing the crucial request_id and the phoneNumber.
Failure: It renders index.ejs again with an error message and the phoneNumber to repopulate the form.
A try...catch block handles potential network errors during the API call.
Page 2: Check Verification Code (View and Route):
Create View (views/verify.ejs):
This page allows the user to enter the code they received via SMS. It includes hidden fields to pass the request_id and phoneNumber back to the server.
HTML
<!-- views/verify.ejs --><!DOCTYPE html><html><head><title>Enter Verification Code</title></head><body><h1>Enter Verification Code</h1><p>Sent to: <%= phoneNumber %></p> <%# Display the number for confirmation %>
<% if (typeof message !== 'undefined' && message) { %>
<pstyle=""color: <%= messageType === 'error' ? 'red' : 'green' %>;""><%= message %></p>
<% } %>
<formmethod=""POST"" action=""/check-verification""><inputtype=""hidden"" name=""requestId"" value=""<%= requestId %>""> <%# Crucial hidden field %>
<inputtype=""hidden"" name=""phoneNumber"" value=""<%= phoneNumber %>""> <%# Pass phone number back %>
<labelfor=""code"">Verification Code:</label><br><inputtype=""text"" id=""code"" name=""code"" requiredminlength=""4"" maxlength=""6""><br><br> <%# OTPs are typically 4-6 digits %>
<buttontype=""submit"">Verify Code</button></form><!-- Optional: Add a cancel button/link here --><formmethod=""POST"" action=""/cancel-verification"" style=""margin-top:10px;""><inputtype=""hidden"" name=""requestId"" value=""<%= requestId %>"">
<inputtype=""hidden"" name=""phoneNumber"" value=""<%= phoneNumber %>"">
<buttontype=""submit"">Cancel Verification</button></form><!-- Optional: Add a resend button/link here (would likely POST to /request-verification again) --></body></html>
It displays the phoneNumber it was sent to.
It uses hidden inputs to send both requestId and phoneNumber back. This is essential for Vonage to check the correct attempt and for re-rendering the page on failure.
The form POSTs to the /check-verification endpoint.
Includes an example ""Cancel Verification"" form posting to /cancel-verification.
Create POST Route /check-verification (index.js):
This route handles the submission from verify.ejs. It receives the requestId, code, and phoneNumber, then calls the Vonage Verify API to check the code. If the check fails, it re-renders verify.ejs with the context.
JavaScript
// index.js (continued)
app.post('/check-verification', async (req, res) => {
const requestId = req.body.requestId;
const code = req.body.code;
const phoneNumber = req.body.phoneNumber; // Retrieve phone number passed from hidden input// Basic validationif (!requestId || !code || !phoneNumber) {
return res.render('verify', { // Re-render verify page with errorrequestId: requestId,
phoneNumber: phoneNumber, // Pass back what we receivedmessage: 'Missing information. Please try entering the code again.',
messageType: 'error'
});
}
console.log(`Checking verification for request ID: ${requestId} with code: ${code}`);
try {
const response = await vonage.verify.check(requestId, code);
console.log('Vonage Verify Check Response:', response);
if (response.status === '0') {
// Verification Successful!// Render the success page
res.render('success', { message: 'Phone number verified successfully!' });
// In a real app: Mark user as verified, log them in, proceed with action, etc.
} else {
// Verification Failed// Render the verify page again with an error message
res.render('verify', {
requestId: requestId,
phoneNumber: phoneNumber, // Pass number back for display and hidden fieldmessage: `Verification failed: ${response.error_text} (Status: ${response.status})`,
messageType: 'error'
});
}
} catch (error) {
// Handle network or other unexpected errorsconsole.error('Error calling Vonage Check API:', error);
res.render('verify', { // Re-render verify pagerequestId: requestId,
phoneNumber: phoneNumber, // Pass number backmessage: 'An unexpected error occurred during verification. Please try again.',
messageType: 'error'
});
}
});
// --- Success page route below ---
It retrieves requestId, code, and crucially phoneNumber from req.body.
Failure: Renders verify.ejs again, passing back the requestId, phoneNumber, and displaying an error message.
Includes try...catch for network errors.
Page 3: Success Page (View):
Create View (views/success.ejs):
A simple confirmation page shown after successful verification.
HTML
<!-- views/success.ejs --><!DOCTYPE html><html><head><title>Verification Successful</title></head><body><h1>Success!</h1>
<% if (typeof message !== 'undefined' && message) { %>
<pstyle=""color:green;""><%= message %></p>
<% } %>
<p>Your phone number has been successfully verified.</p><!-- Add a link back to the start or to the next step in your application --><ahref=""/"">Start Over</a></body></html>
3. Building a Complete API Layer
While this example is a simple web application, the Express routes (/request-verification, /check-verification) effectively act as an API layer if you were building a backend service for a separate frontend (like a React or Vue app) or mobile application.
Considerations for a dedicated API:
Authentication/Authorization: Protect these endpoints if they are part of a larger system. Ensure only authenticated users can initiate verification for their own associated phone numbers. This typically involves session management or token-based authentication (e.g., JWT).
Request Validation: Implement robust validation using libraries like express-validator to check input formats (e.g., ensuring the phone number follows E.164, the code is numeric and has the expected length).
JavaScript
// Example using express-validator in index.js routes// Add validation middleware to the /request-verification route
app.post('/request-verification',
check('number').isMobilePhone('any', { strictMode: false }).withMessage('Invalid phone number format.'), // Basic phone format checkasync (req, res) => {
const errors = validationResult(req);
const phoneNumber = req.body.number; // Get phone number for re-rendering on errorif (!errors.isEmpty()) {
return res.status(400).render('index', { // Use 400 status for bad requestmessage: errors.array()[0].msg, // Show first validation errormessageType: 'error',
phoneNumber: phoneNumber // Pass number back
});
}
// ... rest of the route logic from Section 2 ...
}
);
// Add validation middleware to the /check-verification route
app.post('/check-verification',
check('code').isLength({ min: 4, max: 6 }).isNumeric().withMessage('Invalid code format.'),
check('requestId').notEmpty().withMessage('Request ID is missing.'),
check('phoneNumber').notEmpty().withMessage('Phone number is missing.'), // Also check hidden fieldasync (req, res) => {
const errors = validationResult(req);
const requestId = req.body.requestId; // Get these to re-render verify page correctlyconst phoneNumber = req.body.phoneNumber;
if (!errors.isEmpty()) {
return res.status(400).render('verify', {
requestId: requestId,
phoneNumber: phoneNumber,
message: errors.array()[0].msg,
messageType: 'error'
});
}
// ... rest of the route logic from Section 2 ...
}
);
(Remember: express-validator was installed in Section 1, Step 3)
API Documentation: Use tools like Swagger/OpenAPI to document endpoints, request/response formats (JSON), and status codes if building a true API.
Testing with curl or Postman:
Request Verification:
Bash
curl -X POST http://localhost:3000/request-verification \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "number=YOUR_PHONE_NUMBER"# Replace YOUR_PHONE_NUMBER with a valid E.164 number# Expect HTML response rendering the verify page (if successful)
Check Verification: (Requires a valid requestId and phoneNumber from a previous step)
Bash
curl -X POST http://localhost:3000/check-verification \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "requestId=YOUR_REQUEST_ID&code=YOUR_OTP_CODE&phoneNumber=YOUR_PHONE_NUMBER"# Replace YOUR_REQUEST_ID, YOUR_OTP_CODE, and YOUR_PHONE_NUMBER# Expect HTML response rendering success or verify page
4. Integrating with Necessary Third-Party Services (Vonage)
Configuration: Done via the .env file (VONAGE_API_KEY, VONAGE_API_SECRET, VONAGE_BRAND_NAME).
API Keys/Secrets Security:
Use dotenv to load keys from .env.
Crucially: Add .env to your .gitignore file to prevent accidentally committing secrets to version control.
On deployment platforms (Heroku, AWS, etc.), use their mechanisms for setting environment variables securely – do not store .env files on production servers.
The API Key and API Secret are displayed directly on the landing page under the ""API settings"" section. Copy these values.
Fallback Mechanisms: The Vonage Verify API handles fallbacks automatically within its defined workflows (e.g., SMS -> Voice Call). You generally don't need to implement fallback logic in your application for OTP delivery, unless you want to offer alternative 2FA methods entirely (like authenticator apps).
5. Implementing Proper Error Handling, Logging, and Retry Mechanisms
Error Handling Strategy:
Use try...catch blocks around all Vonage API calls to handle network issues or unexpected exceptions.
Check the status property in the Vonage API response. status === '0' indicates success. Any other status indicates an error specific to the API call (e.g., invalid number, wrong code, insufficient funds).
Use the error_text property from the Vonage response for specific error details.
Provide user-friendly error messages on the frontend (don't expose raw API error details unless necessary for debugging). Pass error messages, types, and necessary context (like phoneNumber, requestId) to the EJS templates for re-rendering forms correctly.
Use appropriate HTTP status codes for API-style responses (e.g., 400 for bad input, 500 for server errors, 429 for rate limiting). Even in this web app, setting res.status(400) before rendering an error page due to invalid input (like in the express-validator example) is good practice.
Logging:
Use console.log for basic informational messages during development (e.g., which number is being verified).
Use console.error for logging errors caught in catch blocks or when Vonage API status is non-zero. Include the Vonage error_text and status code in the log.
Production Logging: For production, replace console.log/console.error with a dedicated logging library like Winston or Pino. These offer structured logging (JSON), different log levels (info, warn, error), and transport options (writing to files, external services).
Vonage Handles Delivery Retries: The Verify API automatically handles retrying SMS delivery and falling back to voice calls based on the selected workflow. You don't need to implement retries for sending the OTP.
Application-Level Retries: You might implement retries in your catch blocks for transient network errors when calling the Vonage API, potentially using exponential backoff. However, for OTP verification, it's often simpler to report the error and let the user try again manually.
User-Initiated Resend: Consider adding a "Resend Code" button on the verify.ejs page. This button would trigger the /request-verification route again with the same phone number. Implement rate limiting on this feature.
6. Creating a Database Schema and Data Layer
For this specific implementation using the Vonage Verify API, no database schema is required on your end to manage the OTP state. Vonage handles:
Generating the code.
Storing the code securely.
Tracking the request_id.
Managing expiry times.
Recording verification attempts.
You would need a database if:
You were building the OTP logic manually using the Vonage Messages API. You'd need to store the generated OTP, its expiry timestamp, the associated user/phone number, and its verification status.
You need to link the verified phone number to a user account in your application's main database. You'd typically have a users table with columns like id, email, password_hash, phone_number, is_phone_verified (boolean), etc. After a successful verification via /check-verification, you would update the is_phone_verified flag for the corresponding user.
7. Adding Security Features
Input Validation and Sanitization:
Use express-validator (shown in Section 3) or similar libraries to validate phone number format and code format/length. This prevents invalid data from reaching the Vonage API and reduces error handling complexity.
Sanitization (removing potentially harmful characters) is less critical for phone numbers and numeric codes but is vital for other user inputs in a larger application.
Protection Against Common Vulnerabilities:
Cross-Site Scripting (XSS): EJS automatically escapes HTML content by default when using <%= ... %>, providing basic XSS protection for data displayed in views. Be cautious if using <%- ... %> (unescaped output).
Cross-Site Request Forgery (CSRF): For web applications with sessions, use CSRF tokens (e.g., with the csurf middleware) to prevent malicious sites from forcing users to submit requests to your application. Less critical for stateless APIs using tokens but still good practice if sessions are used.
Rate Limiting: Essential to prevent abuse and control costs.
Apply rate limiting to the /request-verification endpoint to prevent users from spamming phone numbers with codes.
Apply rate limiting to the /check-verification endpoint to prevent brute-force guessing of OTP codes.
Use middleware like express-rate-limit.
Bash
npm install express-rate-limit
JavaScript
// index.js - Apply rate limitingconst rateLimit = require('express-rate-limit');
const requestVerificationLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutesmax: 5, // Limit each IP to 5 verification requests per windowMsmessage: 'Too many verification requests from this IP, please try again after 15 minutes',
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headerslegacyHeaders: false, // Disable the `X-RateLimit-*` headers// Store configuration can be added for distributed environments
});
const checkVerificationLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutesmax: 10, // Limit each IP to 10 check attempts per windowMs (allows for typos)message: 'Too many code check attempts from this IP, please try again after 5 minutes',
standardHeaders: true,
legacyHeaders: false,
});
// Apply to specific routes *before* the route handlers
app.use('/request-verification', requestVerificationLimiter);
app.use('/check-verification', checkVerificationLimiter);
// If cancel route is heavily used, apply rate limiting there too// app.use('/cancel-verification', checkVerificationLimiter); // Example// ... (rest of your routes)
Brute Force Protection: The Vonage Verify API has built-in mechanisms (like limiting check attempts per request_id). Server-side rate limiting (above) adds another layer.
Secure Key Storage: Reiterating: Use .env and .gitignore locally, and secure environment variable management in production. Never hardcode API keys/secrets.
8. Handling Special Cases Relevant to the Domain
International Phone Numbers: Always aim to collect numbers in E.164 format (e.g., +14155552671, although Vonage often handles numbers without the +). Inform users clearly about the required format. Use frontend libraries (like libphonenumber-js) for input formatting and validation if possible.
Number Formatting: Be prepared for users entering numbers with spaces, dashes, or parentheses. Sanitize the input on the backend before sending it to Vonage (e.g., remove all non-numeric characters except a leading + if using strict E.164). express-validator's isMobilePhone can help, but additional stripping of characters might be needed.
SMS Delivery Issues: SMS is generally reliable but not guaranteed. Vonage's fallback to voice calls helps mitigate this. Inform users that delivery might take a moment and provide a ""Resend Code"" option (with rate limiting).
Code Expiry: Vonage manages code expiry (typically 5 minutes). If a user enters an expired code, the /check-verification call will fail with an appropriate error (status 16: ""The code provided does not match the expected value""). Your error handling should inform the user the code expired and prompt them to request a new one.
Cancel Verification: The Vonage Verify API provides a cancel endpoint (vonage.verify.cancel(requestId)). Implement a route (e.g., /cancel-verification) triggered by a ""Cancel"" button on the verify.ejs page. This invalidates the request_id and prevents further checks against it.
JavaScript
// index.js - Add Cancel Route
app.post('/cancel-verification', async (req, res) => {
const requestId = req.body.requestId;
const phoneNumber = req.body.phoneNumber; // Get phone number for potential redirect/messageif (!requestId) {
// Redirect back to start or show generic errorconsole.error('Cancel attempt without request ID.');
return res.redirect('/');
}
console.log(`Cancelling verification for request ID: ${requestId}`);
try {
const response = await vonage.verify.cancel(requestId);
console.log('Vonage Verify Cancel Response:', response);
if (response.status === '0') {
// Successfully cancelled
res.render('index', { // Render start page with messagemessage: 'Verification cancelled.',
messageType: 'info',
phoneNumber: null// Clear phone number field
});
} elseif (response.status === '19') {
// Status 19: Verification request cannot be cancelled now (already verified or expired)console.warn(`Attempted to cancel already completed/invalid request: ${requestId}`);
res.render('index', {
message: 'Verification process already completed or expired.',
messageType: 'warn',
phoneNumber: null
});
}
else {
// Handle other Vonage API errors during cancelconsole.error('Vonage Verify Cancel Error:', response.error_text);
// Maybe redirect back to verify page with error? Or start page?
res.render('verify', { // Re-render verify page with cancel errorrequestId: requestId,
phoneNumber: phoneNumber,
message: `Error cancelling verification: ${response.error_text}`,
messageType: 'error'
});
}
} catch (error) {
// Handle network or other unexpected errorsconsole.error('Error calling Vonage Cancel API:', error);
res.render('verify', { // Re-render verify page with generic errorrequestId: requestId,
phoneNumber: phoneNumber,
message: 'An unexpected error occurred while cancelling. Please try again.',
messageType: 'error'
});
}
});
User Account Linking: In a real application, after successful verification (/check-verification success), you need logic to associate the verified phone number with the correct user account in your database. This might involve looking up the user by an ID stored in the session or passed via a token.
9. Testing the Implementation
Unit Tests: Test individual functions, especially any utility functions for number sanitization or validation logic you add. Use mocking libraries (like jest.mock) to mock the Vonage SDK calls and test your route handlers' logic without making actual API calls.
Integration Tests: Test the interaction between your Express routes and the (mocked) Vonage API. Ensure the correct data is passed between routes (e.g., requestId, phoneNumber) and that responses are handled correctly. Tools like supertest can be used to make HTTP requests to your running Express app during tests.
End-to-End (E2E) Tests: Use tools like Cypress or Playwright to automate browser interactions. Test the full user flow: entering a phone number, receiving a real SMS (requires a test Vonage account and phone number), entering the code, and verifying success/failure/cancellation. E2E tests are crucial but slower and require managing real credentials/costs.
Manual Testing: Thoroughly test the flow manually with different scenarios:
Valid phone number, valid code.
Valid phone number, invalid code.
Valid phone number, expired code (wait > 5 mins).
Invalid phone number format.
Attempting to check code without requesting first.
Cancelling a request.
Hitting rate limits (if implemented).
Using international numbers.
10. Deployment Considerations
Environment Variables: Use the hosting provider's mechanism for setting environment variables (VONAGE_API_KEY, VONAGE_API_SECRET, VONAGE_BRAND_NAME, PORT, NODE_ENV=production). Do not commit .env files or hardcode secrets.
Process Management: Use a process manager like PM2 or rely on the platform's built-in service management (e.g., systemd, Heroku dynos, Docker container orchestration) to keep the Node.js application running, manage logs, and handle restarts.
HTTPS: Ensure your application is served over HTTPS in production to protect data in transit. Use a reverse proxy like Nginx or Caddy, or leverage platform features (like Heroku Automated Certificate Management, AWS Load Balancer listeners).
Logging: Configure production-level logging (e.g., Winston, Pino) to output structured logs to files or a logging service for monitoring and debugging.
Dependencies: Run npm install --production (or equivalent) to install only necessary production dependencies.
Build Step (if applicable): If using TypeScript or a frontend build process, ensure the build is run before deployment.
Database (if applicable): If linking to a user database, ensure connection strings and credentials are secure and configured via environment variables. Set up database migrations.
Rate Limiting Storage: For distributed deployments (multiple server instances), configure express-rate-limit (or similar) to use a shared store (like Redis or Memcached) so limits are applied across all instances.
Conclusion
By following this guide, you have implemented a secure SMS-based OTP/2FA verification flow in a Node.js Express application using the Vonage Verify API. This leverages Vonage's infrastructure for reliable code generation, delivery (with fallbacks), expiry management, and verification, simplifying your backend development. Remember to prioritize security through input validation, rate limiting, and secure credential management, especially when deploying to production.
Frequently asked questions
Implement OTP SMS verification using Express, the Vonage Verify API, and the Vonage Server SDK. This setup allows you to collect a user's phone number, send a verification code via SMS, and verify the code entered by the user upon its return, leveraging Vonage's robust API for OTP management.
The Vonage Verify API simplifies the complexities of OTP/2FA by handling OTP generation, delivery with retries, code expiry, and verification checks. It's a core component for securely verifying user identity during sign-ups, logins, or sensitive transactions.
Vonage Verify API handles the entire OTP lifecycle, from code generation and SMS/voice delivery to verification and expiry. This offloads the burden of managing these complex processes from the developer, allowing for a simpler and more secure implementation.
Use the Vonage Verify API whenever you need to verify a user's phone number for security purposes, such as during user registration, login, or when authorizing sensitive transactions. It provides a robust and reliable solution for two-factor authentication.
Install the Vonage Server SDK using npm or yarn with the command 'npm install @vonage/server-sdk'. This SDK provides the necessary functions to interact with the Vonage Verify API and other Vonage services within your Node.js application.
The .env file stores sensitive credentials like your Vonage API Key and Secret, keeping them separate from your codebase. It's crucial for security best practices. The 'dotenv' module loads these variables into 'process.env'.
Obtain your Vonage API Key and Secret from the Vonage API Dashboard after signing up for an account. Create a .env file in your project's root directory and add the keys as VONAGE_API_KEY and VONAGE_API_SECRET. Make sure to add .env to your .gitignore file.
Use try...catch blocks around Vonage API calls and check the 'status' property of the response. A '0' status indicates success, while other statuses signal errors detailed in the 'error_text' property. Provide user-friendly feedback without exposing raw API error details.
The article doesn't mention any specific Vonage API costs. You would need to consult the official Vonage pricing documentation to get details of costs involved.
Testing involves unit tests for individual functions, integration tests for interactions between routes and the (mocked) API, and end-to-end tests for the entire user flow. Manual testing with various scenarios, including valid and invalid inputs and edge cases, is also recommended.
Express-validator provides input validation and sanitization to ensure data integrity and security before sending it to the Vonage API. This helps prevent issues such as invalid phone number formats or malicious code injections.
Body-parser is middleware that parses incoming request bodies (like form submissions) in JSON or URL-encoded format. It makes the submitted data accessible in req.body within your Express routes, enabling you to process user inputs.
The Vonage Verify API automatically handles SMS delivery retries and potential fallbacks to voice calls, minimizing the need for custom retry logic. Inform users about potential delays and consider a "Resend Code" option with appropriate rate limiting.
Implement a "Resend Code" functionality by triggering the /request-verification route again with the same phone number, allowing users to request a new code if the original wasn't received. Be sure to add rate limiting to this endpoint to prevent abuse.
This guide provides a complete walkthrough for implementing One-Time Password (OTP) or Two-Factor Authentication (2FA) via SMS in a Node.js application using Express and the Vonage Verify API. We will build a simple web application that requests a user's phone number, sends a verification code via SMS using Vonage, and then verifies the code entered by the user.
This approach simplifies the complexities of OTP generation, delivery retries (including voice call fallbacks), code expiry, and verification checks by leveraging the robust features of the Vonage Verify API.
Project Overview and Goals
What We're Building:
Problem Solved: Securely verifying user identity or actions by confirming possession of a specific phone number, a common requirement for sign-ups, logins, or sensitive transactions.
Technologies Used:
@vonage/server-sdk): Official library to interact with Vonage APIs..envfile.Architecture:
The flow is straightforward:
(Diagrammatically):
Prerequisites:
Expected Outcome: A functional web application demonstrating the OTP/2FA flow using SMS verification powered by Vonage.
1. Setting up the Project
Let's initialize our Node.js project and install the necessary dependencies.
Create Project Directory: Open your terminal or command prompt and create a new directory for your project, then navigate into it:
mkdir vonage-otp-express cd vonage-otp-expressInitialize npm: Create a
package.jsonfile to manage project dependencies and scripts:Install Dependencies: Install Express, the Vonage SDK, EJS for templating,
dotenvfor environment variables,body-parserfor handling form data, andexpress-validatorfor input validation:express: Web application framework.@vonage/server-sdk: To interact with Vonage APIs.dotenv: Loads environment variables from a.envfile intoprocess.env. Crucial for keeping secrets out of code.ejs: Simple templating engine for rendering HTML views with dynamic data.body-parser: Middleware needed to parse JSON and URL-encoded request bodies (like form submissions).express-validator: Middleware for validating incoming request data (like phone numbers and codes).Create Project Structure: Set up a basic structure for organization:
mkdir views public touch index.js .env .gitignoreviews/: Directory to store our EJS template files (.ejs).public/: Directory for static assets like CSS or client-side JavaScript (optional for this guide).index.js: Main application file where our Express server logic will reside..env: File to store sensitive credentials like API keys (will be ignored by Git)..gitignore: Specifies intentionally untracked files that Git should ignore.Configure
.gitignore: Addnode_modulesand.envto your.gitignorefile to prevent committing dependencies and secrets:Configure Environment Variables (
.env): Open the.envfile and add your Vonage API Key and Secret.How to get Vonage API Key and Secret: a. Log in to your Vonage API Dashboard. b. Your API Key and API Secret are displayed prominently on the main dashboard page under "API settings". c. Copy these values into your
.envfile.Replace
YOUR_API_KEYandYOUR_API_SECRETwith your actual credentials.VONAGE_BRAND_NAMEwill be used in the SMS message ("YourAppName code is 1234").2. Implementing Core Functionality (Verify API Workflow)
Now, let's build the Express application logic and the views for our OTP flow.
Initialize Express and Vonage SDK (
index.js): Openindex.jsand set up the basic Express app, configure EJS, load environment variables, initialize the Vonage SDK, and requireexpress-validator.// index.js require('dotenv').config(); // Load .env variables into process.env const express = require('express'); const bodyParser = require('body-parser'); const { Vonage } = require('@vonage/server-sdk'); const { check, validationResult } = require('express-validator'); // For input validation const app = express(); const port = process.env.PORT || 3000; // Initialize Vonage SDK // IMPORTANT: Ensure VONGAGE_API_KEY and VONAGE_API_SECRET are in your .env file if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET) { console.error('Error: VONAGE_API_KEY and VONAGE_API_SECRET must be set in .env file.'); process.exit(1); // Exit if keys are missing } const vonage = new Vonage({ apiKey: process.env.VONAGE_API_KEY, apiSecret: process.env.VONAGE_API_SECRET }); // Middleware Setup app.set('view engine', 'ejs'); // Set EJS as the template engine app.use(bodyParser.json()); // Parse JSON bodies app.use(bodyParser.urlencoded({ extended: true })); // Parse URL-encoded bodies (form submissions) app.use(express.static(__dirname + '/public')); // Serve static files (optional) // --- Routes will go here --- // Start the server app.listen(port, () => { console.log(`Server listening at http://localhost:${port}`); });require('dotenv').config(): Loads variables from.env. Must be called early.Vonagewith the API key and secret fromprocess.env.app.set('view engine', 'ejs'): Tells Express to use EJS for rendering files from theviewsdirectory.bodyParsermiddleware is configured to handle form submissions.express-validatorfunctions (check,validationResult) are imported.Page 1: Request Verification Code (View and Routes):
Create View (
views/index.ejs): This page will contain the form to enter the phone number. It includes a placeholder for displaying messages and pre-fills the number if an error occurred.<!-- views/index.ejs --> <!DOCTYPE html> <html> <head> <title>Enter Phone Number</title> <!-- Add basic styling if desired --> </head> <body> <h1>Enter Your Phone Number for Verification</h1> <% if (typeof message !== 'undefined' && message) { %> <p style=""color: <%= messageType === 'error' ? 'red' : 'green' %>;""><%= message %></p> <% } %> <form method=""POST"" action=""/request-verification""> <label for=""number"">Phone Number (e.g., 14155552671):</label><br> <input type=""tel"" id=""number"" name=""number"" required placeholder=""Include country code"" value=""<%= typeof phoneNumber !== 'undefined' ? phoneNumber : '' %>""><br><br> <button type=""submit"">Send Verification Code</button> </form> </body> </html>messageif passed from the server (used for success/error feedback).POSTs to the/request-verificationendpoint.valueattribute of the input field is set tophoneNumberif it's passed to the template (useful on error).14155552671for US).Create GET Route
/(index.js): This route simply renders theindex.ejsview when the user visits the root URL. Add this insideindex.jsbeforeapp.listen():// index.js (continued) // --- Routes --- app.get('/', (req, res) => { // Render the initial page with the phone number form res.render('index', { message: null, phoneNumber: null }); // Pass null message and number initially }); // --- More routes below ---Create POST Route
/request-verification(index.js): This route handles the form submission fromindex.ejs. It takes the phone number, calls the Vonage Verify API to start the verification process, and then renders the next page (verify.ejs) for code entry. If an error occurs, it re-renders theindex.ejspage with the error message and the previously entered phone number.// index.js (continued) app.post('/request-verification', async (req, res) => { const phoneNumber = req.body.number; const brand = process.env.VONAGE_BRAND_NAME || ""MyApp""; // Use brand from .env or default // Basic validation: Check if number exists if (!phoneNumber) { return res.render('index', { message: 'Phone number is required.', messageType: 'error', phoneNumber: phoneNumber // Pass back the (empty) number }); } console.log(`Requesting verification for number: ${phoneNumber}`); try { const response = await vonage.verify.start({ number: phoneNumber, brand: brand // workflow_id: 6 // Optional: To use SMS only workflow. Default includes SMS -> TTS -> TTS }); console.log('Vonage Verify Start Response:', response); if (response.status === '0') { // Successfully started verification // Render the page to enter the code, passing the request_id and phone number res.render('verify', { requestId: response.request_id, phoneNumber: phoneNumber, // Pass number for display/resend/cancel message: 'Verification code sent. Please check your phone.', messageType: 'success' }); } else { // Handle Vonage API errors console.error('Vonage Verify Start Error:', response.error_text); res.render('index', { message: `Error starting verification: ${response.error_text}`, messageType: 'error', phoneNumber: phoneNumber // Pass number back to the form }); } } catch (error) { // Handle network or other unexpected errors console.error('Error calling Vonage Verify API:', error); res.render('index', { message: 'An unexpected error occurred. Please try again later.', messageType: 'error', phoneNumber: phoneNumber // Pass number back to the form }); } }); // --- More routes below ---numberfrom the request body (req.body).vonage.verify.start().response.status === '0'): It rendersverify.ejs(created next), passing the crucialrequest_idand thephoneNumber.index.ejsagain with an error message and thephoneNumberto repopulate the form.try...catchblock handles potential network errors during the API call.Page 2: Check Verification Code (View and Route):
Create View (
views/verify.ejs): This page allows the user to enter the code they received via SMS. It includes hidden fields to pass therequest_idandphoneNumberback to the server.<!-- views/verify.ejs --> <!DOCTYPE html> <html> <head> <title>Enter Verification Code</title> </head> <body> <h1>Enter Verification Code</h1> <p>Sent to: <%= phoneNumber %></p> <%# Display the number for confirmation %> <% if (typeof message !== 'undefined' && message) { %> <p style=""color: <%= messageType === 'error' ? 'red' : 'green' %>;""><%= message %></p> <% } %> <form method=""POST"" action=""/check-verification""> <input type=""hidden"" name=""requestId"" value=""<%= requestId %>""> <%# Crucial hidden field %> <input type=""hidden"" name=""phoneNumber"" value=""<%= phoneNumber %>""> <%# Pass phone number back %> <label for=""code"">Verification Code:</label><br> <input type=""text"" id=""code"" name=""code"" required minlength=""4"" maxlength=""6""><br><br> <%# OTPs are typically 4-6 digits %> <button type=""submit"">Verify Code</button> </form> <!-- Optional: Add a cancel button/link here --> <form method=""POST"" action=""/cancel-verification"" style=""margin-top: 10px;""> <input type=""hidden"" name=""requestId"" value=""<%= requestId %>""> <input type=""hidden"" name=""phoneNumber"" value=""<%= phoneNumber %>""> <button type=""submit"">Cancel Verification</button> </form> <!-- Optional: Add a resend button/link here (would likely POST to /request-verification again) --> </body> </html>phoneNumberit was sent to.requestIdandphoneNumberback. This is essential for Vonage to check the correct attempt and for re-rendering the page on failure.POSTs to the/check-verificationendpoint./cancel-verification.Create POST Route
/check-verification(index.js): This route handles the submission fromverify.ejs. It receives therequestId,code, andphoneNumber, then calls the Vonage Verify API to check the code. If the check fails, it re-rendersverify.ejswith the context.// index.js (continued) app.post('/check-verification', async (req, res) => { const requestId = req.body.requestId; const code = req.body.code; const phoneNumber = req.body.phoneNumber; // Retrieve phone number passed from hidden input // Basic validation if (!requestId || !code || !phoneNumber) { return res.render('verify', { // Re-render verify page with error requestId: requestId, phoneNumber: phoneNumber, // Pass back what we received message: 'Missing information. Please try entering the code again.', messageType: 'error' }); } console.log(`Checking verification for request ID: ${requestId} with code: ${code}`); try { const response = await vonage.verify.check(requestId, code); console.log('Vonage Verify Check Response:', response); if (response.status === '0') { // Verification Successful! // Render the success page res.render('success', { message: 'Phone number verified successfully!' }); // In a real app: Mark user as verified, log them in, proceed with action, etc. } else { // Verification Failed // Render the verify page again with an error message res.render('verify', { requestId: requestId, phoneNumber: phoneNumber, // Pass number back for display and hidden field message: `Verification failed: ${response.error_text} (Status: ${response.status})`, messageType: 'error' }); } } catch (error) { // Handle network or other unexpected errors console.error('Error calling Vonage Check API:', error); res.render('verify', { // Re-render verify page requestId: requestId, phoneNumber: phoneNumber, // Pass number back message: 'An unexpected error occurred during verification. Please try again.', messageType: 'error' }); } }); // --- Success page route below ---requestId,code, and cruciallyphoneNumberfromreq.body.vonage.verify.check().response.status === '0'): Renderssuccess.ejs(created next).verify.ejsagain, passing back therequestId,phoneNumber, and displaying an error message.try...catchfor network errors.Page 3: Success Page (View):
Create View (
views/success.ejs): A simple confirmation page shown after successful verification.<!-- views/success.ejs --> <!DOCTYPE html> <html> <head> <title>Verification Successful</title> </head> <body> <h1>Success!</h1> <% if (typeof message !== 'undefined' && message) { %> <p style=""color: green;""><%= message %></p> <% } %> <p>Your phone number has been successfully verified.</p> <!-- Add a link back to the start or to the next step in your application --> <a href=""/"">Start Over</a> </body> </html>3. Building a Complete API Layer
While this example is a simple web application, the Express routes (
/request-verification,/check-verification) effectively act as an API layer if you were building a backend service for a separate frontend (like a React or Vue app) or mobile application.Considerations for a dedicated API:
express-validatorto check input formats (e.g., ensuring the phone number follows E.164, the code is numeric and has the expected length).// Example using express-validator in index.js routes // Add validation middleware to the /request-verification route app.post('/request-verification', check('number').isMobilePhone('any', { strictMode: false }).withMessage('Invalid phone number format.'), // Basic phone format check async (req, res) => { const errors = validationResult(req); const phoneNumber = req.body.number; // Get phone number for re-rendering on error if (!errors.isEmpty()) { return res.status(400).render('index', { // Use 400 status for bad request message: errors.array()[0].msg, // Show first validation error messageType: 'error', phoneNumber: phoneNumber // Pass number back }); } // ... rest of the route logic from Section 2 ... } ); // Add validation middleware to the /check-verification route app.post('/check-verification', check('code').isLength({ min: 4, max: 6 }).isNumeric().withMessage('Invalid code format.'), check('requestId').notEmpty().withMessage('Request ID is missing.'), check('phoneNumber').notEmpty().withMessage('Phone number is missing.'), // Also check hidden field async (req, res) => { const errors = validationResult(req); const requestId = req.body.requestId; // Get these to re-render verify page correctly const phoneNumber = req.body.phoneNumber; if (!errors.isEmpty()) { return res.status(400).render('verify', { requestId: requestId, phoneNumber: phoneNumber, message: errors.array()[0].msg, messageType: 'error' }); } // ... rest of the route logic from Section 2 ... } );express-validatorwas installed in Section 1, Step 3)curlor Postman:curl -X POST http://localhost:3000/request-verification \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "number=YOUR_PHONE_NUMBER" # Replace YOUR_PHONE_NUMBER with a valid E.164 number # Expect HTML response rendering the verify page (if successful)requestIdandphoneNumberfrom a previous step)curl -X POST http://localhost:3000/check-verification \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "requestId=YOUR_REQUEST_ID&code=YOUR_OTP_CODE&phoneNumber=YOUR_PHONE_NUMBER" # Replace YOUR_REQUEST_ID, YOUR_OTP_CODE, and YOUR_PHONE_NUMBER # Expect HTML response rendering success or verify page4. Integrating with Necessary Third-Party Services (Vonage)
.envfile (VONAGE_API_KEY,VONAGE_API_SECRET,VONAGE_BRAND_NAME).dotenvto load keys from.env..envto your.gitignorefile to prevent accidentally committing secrets to version control..envfiles on production servers.5. Implementing Proper Error Handling, Logging, and Retry Mechanisms
try...catchblocks around all Vonage API calls to handle network issues or unexpected exceptions.statusproperty in the Vonage API response.status === '0'indicates success. Any other status indicates an error specific to the API call (e.g., invalid number, wrong code, insufficient funds).error_textproperty from the Vonage response for specific error details.phoneNumber,requestId) to the EJS templates for re-rendering forms correctly.400for bad input,500for server errors,429for rate limiting). Even in this web app, settingres.status(400)before rendering an error page due to invalid input (like in theexpress-validatorexample) is good practice.console.logfor basic informational messages during development (e.g., which number is being verified).console.errorfor logging errors caught incatchblocks or when Vonage API status is non-zero. Include the Vonageerror_textandstatuscode in the log.console.log/console.errorwith a dedicated logging library like Winston or Pino. These offer structured logging (JSON), different log levels (info, warn, error), and transport options (writing to files, external services).// Example: Logging Vonage errors if (response.status !== '0') { console.error(`Vonage API Error: Status ${response.status}, Text: ${response.error_text}, RequestID: ${response.request_id || requestId || 'N/A'}`); // Render error page... }catchblocks for transient network errors when calling the Vonage API, potentially using exponential backoff. However, for OTP verification, it's often simpler to report the error and let the user try again manually.verify.ejspage. This button would trigger the/request-verificationroute again with the same phone number. Implement rate limiting on this feature.6. Creating a Database Schema and Data Layer
For this specific implementation using the Vonage Verify API, no database schema is required on your end to manage the OTP state. Vonage handles:
request_id.You would need a database if:
userstable with columns likeid,email,password_hash,phone_number,is_phone_verified(boolean), etc. After a successful verification via/check-verification, you would update theis_phone_verifiedflag for the corresponding user.7. Adding Security Features
express-validator(shown in Section 3) or similar libraries to validate phone number format and code format/length. This prevents invalid data from reaching the Vonage API and reduces error handling complexity.<%= ... %>, providing basic XSS protection for data displayed in views. Be cautious if using<%- ... %>(unescaped output).csurfmiddleware) to prevent malicious sites from forcing users to submit requests to your application. Less critical for stateless APIs using tokens but still good practice if sessions are used./request-verificationendpoint to prevent users from spamming phone numbers with codes./check-verificationendpoint to prevent brute-force guessing of OTP codes.express-rate-limit.// index.js - Apply rate limiting const rateLimit = require('express-rate-limit'); const requestVerificationLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // Limit each IP to 5 verification requests per windowMs message: 'Too many verification requests from this IP, please try again after 15 minutes', standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers // Store configuration can be added for distributed environments }); const checkVerificationLimiter = rateLimit({ windowMs: 5 * 60 * 1000, // 5 minutes max: 10, // Limit each IP to 10 check attempts per windowMs (allows for typos) message: 'Too many code check attempts from this IP, please try again after 5 minutes', standardHeaders: true, legacyHeaders: false, }); // Apply to specific routes *before* the route handlers app.use('/request-verification', requestVerificationLimiter); app.use('/check-verification', checkVerificationLimiter); // If cancel route is heavily used, apply rate limiting there too // app.use('/cancel-verification', checkVerificationLimiter); // Example // ... (rest of your routes)request_id). Server-side rate limiting (above) adds another layer..envand.gitignorelocally, and secure environment variable management in production. Never hardcode API keys/secrets.8. Handling Special Cases Relevant to the Domain
+14155552671, although Vonage often handles numbers without the+). Inform users clearly about the required format. Use frontend libraries (likelibphonenumber-js) for input formatting and validation if possible.+if using strict E.164).express-validator'sisMobilePhonecan help, but additional stripping of characters might be needed./check-verificationcall will fail with an appropriate error (status16: ""The code provided does not match the expected value""). Your error handling should inform the user the code expired and prompt them to request a new one.cancelendpoint (vonage.verify.cancel(requestId)). Implement a route (e.g.,/cancel-verification) triggered by a ""Cancel"" button on theverify.ejspage. This invalidates therequest_idand prevents further checks against it.// index.js - Add Cancel Route app.post('/cancel-verification', async (req, res) => { const requestId = req.body.requestId; const phoneNumber = req.body.phoneNumber; // Get phone number for potential redirect/message if (!requestId) { // Redirect back to start or show generic error console.error('Cancel attempt without request ID.'); return res.redirect('/'); } console.log(`Cancelling verification for request ID: ${requestId}`); try { const response = await vonage.verify.cancel(requestId); console.log('Vonage Verify Cancel Response:', response); if (response.status === '0') { // Successfully cancelled res.render('index', { // Render start page with message message: 'Verification cancelled.', messageType: 'info', phoneNumber: null // Clear phone number field }); } else if (response.status === '19') { // Status 19: Verification request cannot be cancelled now (already verified or expired) console.warn(`Attempted to cancel already completed/invalid request: ${requestId}`); res.render('index', { message: 'Verification process already completed or expired.', messageType: 'warn', phoneNumber: null }); } else { // Handle other Vonage API errors during cancel console.error('Vonage Verify Cancel Error:', response.error_text); // Maybe redirect back to verify page with error? Or start page? res.render('verify', { // Re-render verify page with cancel error requestId: requestId, phoneNumber: phoneNumber, message: `Error cancelling verification: ${response.error_text}`, messageType: 'error' }); } } catch (error) { // Handle network or other unexpected errors console.error('Error calling Vonage Cancel API:', error); res.render('verify', { // Re-render verify page with generic error requestId: requestId, phoneNumber: phoneNumber, message: 'An unexpected error occurred while cancelling. Please try again.', messageType: 'error' }); } });/check-verificationsuccess), you need logic to associate the verified phone number with the correct user account in your database. This might involve looking up the user by an ID stored in the session or passed via a token.9. Testing the Implementation
jest.mock) to mock the Vonage SDK calls and test your route handlers' logic without making actual API calls.requestId,phoneNumber) and that responses are handled correctly. Tools likesupertestcan be used to make HTTP requests to your running Express app during tests.10. Deployment Considerations
VONAGE_API_KEY,VONAGE_API_SECRET,VONAGE_BRAND_NAME,PORT,NODE_ENV=production). Do not commit.envfiles or hardcode secrets.npm install --production(or equivalent) to install only necessary production dependencies.express-rate-limit(or similar) to use a shared store (like Redis or Memcached) so limits are applied across all instances.Conclusion
By following this guide, you have implemented a secure SMS-based OTP/2FA verification flow in a Node.js Express application using the Vonage Verify API. This leverages Vonage's infrastructure for reliable code generation, delivery (with fallbacks), expiry management, and verification, simplifying your backend development. Remember to prioritize security through input validation, rate limiting, and secure credential management, especially when deploying to production.