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).
- Manages code validity and retries.
- Verifies submitted codes against its records.
- Returns verification results to the Express app.
(Diagrammatically):
[Browser] <--> [Express App (Node.js + Vonage SDK)] <--> [Vonage Verify API] --> [User's Phone (SMS)]
Prerequisites:
- Node.js and npm (or yarn): Installed on your development machine. Download Node.js
- Vonage API Account: Required to access the Verify API. Sign up for a free Vonage account – you'll get free credit to start.
- Vonage API Key and Secret: Found on the Vonage API Dashboard after signing up.
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-express
-
Initialize npm: Create a
package.json
file to manage project dependencies and scripts:npm init -y
-
Install Dependencies: Install Express, the Vonage SDK, EJS for templating,
dotenv
for environment variables,body-parser
for handling form data, andexpress-validator
for input validation:npm install express @vonage/server-sdk dotenv ejs body-parser express-validator
express
: Web application framework.@vonage/server-sdk
: To interact with Vonage APIs.dotenv
: Loads environment variables from a.env
file 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 .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
: Addnode_modules
and.env
to your.gitignore
file to prevent committing dependencies and secrets:# .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 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
andYOUR_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
): Openindex.js
and 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.- We initialize
Vonage
with the API key and secret fromprocess.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 theviews
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.<!-- views/index.ejs --> <!DOCTYPE html> <html> <head> <title>Enter Phone Number</title> <!-- Add basic styling if desired --> </head> <body> <h1>Enter Your Phone Number for Verification</h1> <% if (typeof message !== 'undefined' && message) { %> <p style=""color: <%= messageType === 'error' ? 'red' : 'green' %>;""><%= message %></p> <% } %> <form method=""POST"" action=""/request-verification""> <label for=""number"">Phone Number (e.g., 14155552671):</label><br> <input type=""tel"" id=""number"" name=""number"" required placeholder=""Include country code"" value=""<%= typeof phoneNumber !== 'undefined' ? phoneNumber : '' %>""><br><br> <button type=""submit"">Send Verification Code</button> </form> </body> </html>
- It displays a
message
if passed from the server (used for success/error feedback). - The form
POST
s to the/request-verification
endpoint. - The
value
attribute of the input field is set tophoneNumber
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).
- It displays a
-
Create GET Route
/
(index.js
): This route simply renders theindex.ejs
view when the user visits the root URL. Add this insideindex.js
beforeapp.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.ejs
page 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 ---
- It retrieves the
number
from the request body (req.body
). - It calls
vonage.verify.start()
. - Success (
response.status === '0'
): It rendersverify.ejs
(created next), passing the crucialrequest_id
and thephoneNumber
. - Failure: It renders
index.ejs
again with an error message and thephoneNumber
to repopulate the form. - A
try...catch
block handles potential network errors during the API call.
- It retrieves the
-
-
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_id
andphoneNumber
back 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>
- It displays the
phoneNumber
it was sent to. - It uses hidden inputs to send both
requestId
andphoneNumber
back. This is essential for Vonage to check the correct attempt and for re-rendering the page on failure. - The form
POST
s to the/check-verification
endpoint. - Includes an example ""Cancel Verification"" form posting to
/cancel-verification
.
- It displays the
-
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.ejs
with 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 ---
- It retrieves
requestId
,code
, and cruciallyphoneNumber
fromreq.body
. - It calls
vonage.verify.check()
. - Success (
response.status === '0'
): Renderssuccess.ejs
(created next). - Failure: Renders
verify.ejs
again, passing back therequestId
,phoneNumber
, and displaying an error message. - Includes
try...catch
for network errors.
- It retrieves
-
-
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:
- 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).(Remember:// 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-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:
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
andphoneNumber
from 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 page
- Request Verification:
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.
- Use
- Dashboard Navigation for Credentials:
- Go to the Vonage API Dashboard.
- Log in to your account.
- 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, settingres.status(400)
before rendering an error page due to invalid input (like in theexpress-validator
example) is good practice.
- Use
- Logging:
- Use
console.log
for basic informational messages during development (e.g., which number is being verified). - Use
console.error
for logging errors caught incatch
blocks or when Vonage API status is non-zero. Include the Vonageerror_text
andstatus
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).
// 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... }
- Use
- Retry Mechanisms:
- Vonage Handles Delivery Retries: The Verify API automatically handles retrying SMS delivery and falling back to voice calls based on the selected workflow. You don't need to implement retries for sending the OTP.
- Application-Level Retries: You might implement retries in your
catch
blocks for transient network errors when calling the Vonage API, potentially using exponential backoff. However, for OTP verification, it's often simpler to report the error and let the user try again manually. - User-Initiated Resend: Consider adding a "Resend Code" button on the
verify.ejs
page. This button would trigger the/request-verification
route again with the same phone number. Implement rate limiting on this feature.
6. Creating a Database Schema and Data Layer
For this specific implementation using the Vonage Verify API, no database schema is required on your end to manage the OTP state. Vonage handles:
- Generating the code.
- Storing the code securely.
- Tracking the
request_id
. - Managing expiry times.
- Recording verification attempts.
You would need a database if:
- You were building the OTP logic manually using the Vonage Messages API. You'd need to store the generated OTP, its expiry timestamp, the associated user/phone number, and its verification status.
- You need to link the verified phone number to a user account in your application's main database. You'd typically have a
users
table with columns likeid
,email
,password_hash
,phone_number
,is_phone_verified
(boolean), etc. After a successful verification via/check-verification
, you would update theis_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.
- Use
- 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.
- Cross-Site Scripting (XSS): EJS automatically escapes HTML content by default when using
- 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
.
npm install 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)
- Apply rate limiting to the
- 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 (likelibphonenumber-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
'sisMobilePhone
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 theverify.ejs
page. This invalidates therequest_id
and 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' }); } });
- 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 likesupertest
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.