This guide provides a step-by-step walkthrough for integrating Vonage's Verify API V2 to add robust Two-Factor Authentication (OTP via SMS/Voice) to your web application using a Next.js frontend and a Node.js/Express backend.
We'll build a secure, user-friendly OTP verification flow from scratch, covering setup, core implementation, security considerations, error handling, and deployment.
Project Overview and Goals
What We're Building:
We will create a simple application demonstrating a 2FA flow:
- A user enters their phone number on a Next.js frontend.
- The frontend sends the number to a Node.js/Express backend API.
- The backend uses the Vonage Verify V2 API to initiate an OTP request (sending a code via SMS or voice call to the user's phone).
- The user receives the code and enters it into the Next.js frontend.
- The frontend sends the code and a unique request identifier back to the backend API. (Note: For simplicity, this guide sends the request ID to the client. A more secure approach, discussed in Section 6, involves storing it in a server-side session.)
- The backend uses the Vonage Verify V2 API to check the code's validity.
- The frontend displays a success or failure message to the user.
Problem Solved:
This implementation adds a critical layer of security beyond traditional passwords. By requiring users to possess their phone to receive and enter a time-sensitive code, it significantly reduces the risk of unauthorized account access even if passwords are compromised.
Technologies Used:
- Frontend: Next.js (React Framework) – Chosen for its modern features, performance optimizations, and excellent developer experience for building user interfaces. We'll use the App Router.
- Backend: Node.js with Express – A robust and widely-adopted combination for building efficient and scalable APIs. This Express server runs separately from the Next.js application.
- Authentication Service: Vonage Verify API V2 – Provides a managed, reliable, and global service for sending and verifying OTPs across multiple channels (SMS, Voice), simplifying the complex infrastructure required for 2FA.
- Vonage Node SDK (
@vonage/server-sdk
): Simplifies interaction with the Vonage API from our Node.js backend. - Environment Management:
dotenv
– To securely manage API keys and configuration variables. - API Communication: Standard
fetch
API in Next.js,express.json()
andcors
middleware in Express.
System Architecture:
+-----------------+ +-------------------+ +-------------------+ +-----------------+
| User Browser | ---> | Next.js Frontend | ---> | Node.js/Express | ---> | Vonage Verify |
| (Input Phone/ | | (React Components,| | Backend API | | API V2 |
| Input Code) | | API Calls) | | (/request, /check)| | (Send/Check OTP)|
+-----------------+ +--------^----------+ +--------^----------+ +-----------------+
| | |
| Success/Error | Success/Error + ReqID* | Success/Error
+------------------------+------------------------+
*Note: Sending ReqID to client is less secure. See Sec 6.
Prerequisites:
- Node.js (v18 or later recommended) and npm/yarn installed.
- A Vonage API account. You can sign up for free credit.
- You will need your API Key and API Secret from the Vonage API Dashboard.
- Basic understanding of JavaScript, React, Next.js, and Node.js/Express.
- A text editor (like VS Code) and a terminal.
Expected Outcome:
By the end of this guide, you will have a functional application with a 2FA flow. Users can trigger an OTP, receive it on their phone, enter it, and have it verified. You will also understand the principles of integrating Vonage Verify V2, handling potential errors, and implementing security best practices.
1. Setting up the Project
We'll create separate directories for the frontend and backend for clarity.
1.1. Create Project Structure:
Open your terminal and create a main project directory, then navigate into it.
mkdir vonage-otp-app
cd vonage-otp-app
Create directories for the frontend and backend:
mkdir frontend backend
1.2. Backend Setup (Node.js/Express):
Navigate into the backend
directory:
cd backend
Initialize the Node.js project:
npm init -y
This creates a package.json
file.
Install necessary dependencies:
express
: The web framework.@vonage/server-sdk
: The official Vonage Node.js SDK (V3+ for Verify V2).dotenv
: To load environment variables from a.env
file.cors
: To enable Cross-Origin Resource Sharing (needed because frontend and backend run on different ports during development).
npm install express @vonage/server-sdk dotenv cors
Create the main server file:
touch server.js
Create a .env
file to store sensitive credentials:
touch .env
Open the .env
file and add your Vonage API credentials and a brand name. IMPORTANT: Replace the placeholder values (YOUR_API_KEY
, YOUR_API_SECRET
) with your actual credentials found on the Vonage Dashboard.
# backend/.env
VONAGE_API_KEY=YOUR_API_KEY
VONAGE_API_SECRET=YOUR_API_SECRET
VONAGE_BRAND_NAME=MyAppName # Brand name shown in SMS message (max 11 chars alphanumeric)
PORT=5001 # Port for the backend server
Explanation of .env
Variables:
VONAGE_API_KEY
: Your unique identifier for accessing the Vonage API. Found on your Vonage Dashboard.VONAGE_API_SECRET
: Your secret key for authenticating API requests. Found on your Vonage Dashboard. Treat this like a password.VONAGE_BRAND_NAME
: The name displayed as the sender in the OTP message. Helps users identify the origin of the code.PORT
: The network port your Express server will listen on.
IMPORTANT: Secure Your Credentials
Create a .gitignore
file in the backend
directory to prevent accidentally committing your secrets:
touch .gitignore
Add the following lines to backend/.gitignore
:
# backend/.gitignore
node_modules/
.env
1.3. Frontend Setup (Next.js):
Navigate back to the root vonage-otp-app
directory and then into the frontend
directory:
cd ../frontend
Create a new Next.js application using the App Router:
npx create-next-app@latest . --typescript=no --eslint=yes --tailwind=no --src-dir=no --app=yes --import-alias="@/*"
(Answer prompts as needed. We chose no TypeScript/Tailwind/src dir for simplicity here, but feel free to adjust).
This command scaffolds a new Next.js project in the current directory (.
).
The frontend doesn't require additional dependencies for this core functionality.
Add Frontend .gitignore
:
Ensure your frontend/.gitignore
file (usually created by create-next-app
) includes lines to ignore local environment files and build artifacts:
# frontend/.gitignore (ensure these lines exist or add them)
node_modules/
.next/
out/
.env*.local
2. Implementing Core Functionality (Backend API)
We'll now build the Express API endpoints that will interact with the Vonage Verify V2 API.
2.1. Basic Express Server Setup:
Open the backend/server.js
file and add the following initial setup:
// backend/server.js
require('dotenv').config(); // Load environment variables from .env file
const express = require('express');
const cors = require('cors');
const { Vonage } = require('@vonage/server-sdk'); // Vonage SDK V3+
const app = express();
const port = process.env.PORT || 5001;
// --- Vonage Client Initialization ---
// Ensure API Key and Secret are loaded
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 credentials are missing
}
const vonage = new Vonage({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET,
});
// --- Middleware ---
app.use(cors()); // Enable CORS for requests from the frontend (adjust in production)
app.use(express.json()); // Parse JSON request bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded request bodies
// --- API Routes (To be added below) ---
app.get('/health', (req, res) => {
res.status(200).json({ status: 'OK', message: 'Backend is running' });
}); // Basic health check endpoint
// --- Start Server ---
app.listen(port, () => {
console.log(`Backend server listening at http://localhost:${port}`);
});
Explanation:
require('dotenv').config()
: Loads variables from the.env
file intoprocess.env
. Crucial for accessing credentials.new Vonage(...)
: Initializes the Vonage SDK client with your credentials.app.use(cors())
: Allows requests from your Next.js frontend (running on a different port, e.g., 3000) to reach the backend (running on 5001). For production, configure CORS more restrictively.app.use(express.json())
: Enables the server to understand incoming request bodies formatted as JSON./health
route: A simple endpoint to check if the server is running.
2.2. Implementing the /api/request-verification
Endpoint:
This endpoint receives a phone number from the frontend and triggers the Vonage OTP request using Verify V2.
Add the following route before the app.listen
call in backend/server.js
:
// backend/server.js
// ... (previous setup code) ...
// --- API Routes ---
// Endpoint to request a verification code (Verify V2)
app.post('/api/request-verification', async (req, res) => {
const { phoneNumber } = req.body;
// Basic validation (more robust validation recommended in production)
if (!phoneNumber) {
return res.status(400).json({ error: 'Phone number is required.' });
}
console.log(`Requesting verification for: ${phoneNumber}`);
try {
const result = await vonage.verify.start({
number: phoneNumber,
brand: process.env.VONAGE_BRAND_NAME || 'MyApp',
code_length: 6 // Request a 6-digit code (default is 4 for V1, 6 is common)
// workflow_id: Can specify SMS/Voice/Silent Auth etc. if needed
});
console.log('Verification request sent successfully. Request ID:', result.request_id);
// ** SECURITY WARNING **
// Sending the request_id back to the client is simpler for this demo,
// but LESS SECURE. The client could potentially misuse it.
// A better approach (see Section 6) is to store the request_id
// in a server-side session associated with the user.
return res.status(200).json({ requestId: result.request_id });
} catch (error) {
console.error('Vonage API Error:', error);
// Try to extract a meaningful error message
let errorMessage = 'Failed to initiate verification.';
let statusCode = 500;
if (error.response && error.response.data) {
// Handle errors structured according to Vonage API V2 spec
errorMessage = error.response.data.title || error.response.data.detail || errorMessage;
if (error.response.data.type && error.response.data.type.includes('validation')) {
statusCode = 400; // Bad request (e.g., invalid number)
}
if (error.response.status === 429) {
statusCode = 429; // Throttling
errorMessage = 'Too many requests. Please try again later.';
} else {
statusCode = error.response.status || 500;
}
console.error('Vonage Error Details:', error.response.data);
} else if (error.message) {
errorMessage = error.message;
}
return res.status(statusCode).json({ error: errorMessage });
}
});
// ... (health check route and app.listen) ...
Explanation:
- The route listens for POST requests at
/api/request-verification
. It's nowasync
. - It extracts the
phoneNumber
from the JSON request body (req.body
). - Basic validation checks if the phone number exists.
vonage.verify.start()
(Verify V2 method) is called usingawait
:number
: The user's phone number (should be in E.164 format ideally, e.g., +14155552671).brand
: The sender name from your.env
file.code_length
: Set to 6 for a 6-digit OTP.
- Error Handling: A
try...catch
block handles potential errors during the API call. Vonage SDK V3+ throws errors on failure. We inspect theerror
object (oftenerror.response.data
for API errors) to provide a more specific message and status code. requestId
Handling (Security Note): Upon success, therequest_id
is returned. The code includes a prominent warning explaining that sending this to the client is less secure and refers to Section 6 for the recommended server-side session approach.
2.3. Implementing the /api/check-verification
Endpoint:
This endpoint receives the requestId
(obtained from the previous step) and the OTP code entered by the user, then checks their validity with Vonage Verify V2.
Add the following route before the app.listen
call in backend/server.js
:
// backend/server.js
// ... (previous setup code and /api/request-verification route) ...
// Endpoint to check the verification code (Verify V2)
app.post('/api/check-verification', async (req, res) => {
const { requestId, code } = req.body;
// Basic validation
if (!requestId || !code) {
return res.status(400).json({ error: 'Request ID and code are required.' });
}
if (code.length !== 6 || !/^\d+$/.test(code)) { // Basic check for 6 digits
return res.status(400).json({ error: 'Invalid code format. Must be 6 digits.' });
}
console.log(`Checking verification for Request ID: ${requestId} with Code: ${code}`);
try {
const result = await vonage.verify.check({
request_id: requestId,
code: code
});
// Verify V2 'check' returns an empty body on success (status 200 OK)
// If it doesn't throw an error, the code is correct.
console.log('Verification successful for Request ID:', requestId);
// In a real app, you would now:
// 1. Mark the user's 2FA as verified for this session (if using sessions).
// 2. Potentially link this device/verification if needed.
// 3. Grant access to the protected resource/complete login.
return res.status(200).json({ message: 'Verification successful.' });
} catch (error) {
console.error('Vonage API Check Error:', error);
let errorMessage = 'Failed to check verification code.';
let statusCode = 500;
if (error.response && error.response.data) {
// Handle specific V2 check errors
errorMessage = error.response.data.title || error.response.data.detail || errorMessage;
statusCode = error.response.status || 500; // Use status from Vonage response
if (statusCode === 400) { // Often incorrect code
errorMessage = 'The code you entered was incorrect or has expired. Please try again.';
} else if (statusCode === 410) { // Gone - request expired or already used
errorMessage = 'Verification request not found, has expired, or already used. Please request a new code.';
} else if (statusCode === 429) { // Throttling on check attempts
errorMessage = 'Too many check attempts. Please request a new code.';
}
console.error('Vonage Check Error Details:', error.response.data);
} else if (error.message) {
errorMessage = error.message;
}
return res.status(statusCode).json({ error: errorMessage });
}
});
// ... (health check route and app.listen) ...
Explanation:
- The route listens for POST requests at
/api/check-verification
. It'sasync
. - It extracts the
requestId
and thecode
entered by the user. - Basic validation checks if both fields exist and if the code looks like a 6-digit number.
vonage.verify.check()
(Verify V2 method) is called usingawait
:request_id
: The ID of the verification attempt.code
: The OTP code entered by the user.
- Error Handling & Status Check:
- Verify V2's
check
method succeeds by returning a 200 OK status without throwing an error. If theawait
completes without error, the code is valid. - If the code is invalid, expired, or the request ID doesn't exist, the SDK throws an error. The
catch
block inspectserror.response
to determine the cause (e.g., status codes 400, 410, 429) and provides appropriate user feedback.
- Verify V2's
- A success (200 OK) or error JSON response is sent to the frontend.
3. Building the Frontend Interface (Next.js)
Now, let's create the user interface in Next.js to interact with our backend API.
3.1. Create the Page Component:
Replace the contents of frontend/app/page.js
with the following code:
// frontend/app/page.js
'use client'; // Required for components with hooks like useState, useEffect
import React, { useState } from 'react';
export default function HomePage() {
const [phoneNumber, setPhoneNumber] = useState('');
const [code, setCode] = useState('');
const [requestId, setRequestId] = useState('');
const [verificationStep, setVerificationStep] = useState('request'); // 'request' or 'check'
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState(''); // For success/error messages
const [messageType, setMessageType] = useState(''); // 'success' or 'error'
// --- Configuration ---
// Use environment variables in a real app for the backend URL
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:5001';
// --- Handlers ---
const handleRequestCode = async (e) => {
e.preventDefault();
setIsLoading(true);
setMessage('');
setMessageType('');
try {
const response = await fetch(`${BACKEND_URL}/api/request-verification`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phoneNumber }),
});
const data = await response.json();
if (!response.ok) {
// Handle errors from the backend API
throw new Error(data.error || `HTTP error! status: ${response.status}`);
}
// Success! Store request ID and move to the next step
// Note: Storing sensitive IDs client-side has security implications. See backend notes.
setRequestId(data.requestId);
setVerificationStep('check');
setMessage('Verification code sent! Please check your phone.');
setMessageType('success');
} catch (error) {
console.error('Request Code Error:', error);
setMessage(error.message || 'Failed to send verification code. Please try again.');
setMessageType('error');
} finally {
setIsLoading(false);
}
};
const handleCheckCode = async (e) => {
e.preventDefault();
setIsLoading(true);
setMessage('');
setMessageType('');
try {
const response = await fetch(`${BACKEND_URL}/api/check-verification`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, code }), // Send requestId stored in state
});
const data = await response.json();
if (!response.ok) {
// Handle errors from the backend API
throw new Error(data.error || `HTTP error! status: ${response.status}`);
}
// Verification successful!
setMessage('Verification successful! Welcome!');
setMessageType('success');
// In a real app, redirect or grant access here
// Reset state for potential future use (optional)
// setVerificationStep('request');
// setPhoneNumber('');
// setCode('');
// setRequestId('');
} catch (error) {
console.error('Check Code Error:', error);
setMessage(error.message || 'Verification failed. Please check the code or request a new one.');
setMessageType('error');
// Optionally allow user to go back or retry
// if (error.message.includes('expired')) {
// setVerificationStep('request'); // Go back if expired
// }
} finally {
setIsLoading(false);
}
};
// --- Render Logic ---
return (
<div style={styles.container}>
<h1>Vonage OTP/2FA Demo</h1>
{message && (
<p style={messageType === 'error' ? styles.errorMessage : styles.successMessage}>
{message}
</p>
)}
{verificationStep === 'request' && (
<form onSubmit={handleRequestCode} style={styles.form}>
<h2>Step 1: Enter Phone Number</h2>
<p>Enter your phone number (e.g., +14155552671) to receive a verification code.</p>
<input
type="tel" // Use standard tel type
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="+12345678900"
required
style={styles.input}
disabled={isLoading}
/>
<button type="submit" style={styles.button} disabled={isLoading}>
{isLoading ? 'Sending...' : 'Send Code'}
</button>
</form>
)}
{verificationStep === 'check' && (
<form onSubmit={handleCheckCode} style={styles.form}>
<h2>Step 2: Enter Verification Code</h2>
<p>Enter the 6-digit code sent to {phoneNumber}.</p>
<input
type="text" // Use "text" with pattern for better mobile input suggestions
inputMode="numeric" // Hint for numeric keyboard
pattern="\d{6}" // Basic pattern for 6 digits
maxLength="6"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="123456"
required
style={styles.input}
disabled={isLoading}
/>
<button type="submit" style={styles.button} disabled={isLoading}>
{isLoading ? 'Verifying...' : 'Verify Code'}
</button>
<button
type="button"
onClick={() => {
setVerificationStep('request');
setMessage('');
setCode(''); // Clear code if going back
}}
style={{...styles.button, ...styles.secondaryButton}}
disabled={isLoading}
>
Change Phone Number
</button>
</form>
)}
</div>
);
}
// Basic inline styles for demonstration
const styles = {
container: {
maxWidth: '500px',
margin: '50px auto',
padding: '20px',
fontFamily: 'Arial, sans-serif',
border: '1px solid #ccc',
borderRadius: '8px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
},
form: {
display: 'flex',
flexDirection: 'column',
gap: '15px',
},
input: {
padding: '10px',
fontSize: '1rem',
border: '1px solid #ccc',
borderRadius: '4px',
},
button: {
padding: '12px 15px',
fontSize: '1rem',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
transition: 'background-color 0.2s ease',
},
secondaryButton: {
backgroundColor: '#6c757d', // A different color for secondary actions
},
errorMessage: {
color: 'red',
backgroundColor: '#ffebee',
padding: '10px',
borderRadius: '4px',
border: '1px solid red',
},
successMessage: {
color: 'green',
backgroundColor: '#e8f5e9',
padding: '10px',
borderRadius: '4px',
border: '1px solid green',
},
};
Explanation:
'use client'
: Directive required by Next.js App Router for client-side hooks.- State Variables: Manage phone number, code, request ID, UI step, loading state, and feedback messages.
BACKEND_URL
: Points to the Express API. UsesNEXT_PUBLIC_BACKEND_URL
if set.handleRequestCode
: Makes the POST request to/api/request-verification
. On success, stores therequestId
(with the security caveat mentioned) and moves to the 'check' step.handleCheckCode
: Makes the POST request to/api/check-verification
with the storedrequestId
and the enteredcode
. Shows success or error messages.- Render Logic: Conditionally displays the correct form based on
verificationStep
. Uses basic inline styles and provides user feedback. Inputtype
attributes are corrected to use standard quotes (e.g.,type="tel"
).
3.2. Environment Variable for Frontend (Optional but Recommended):
Create a .env.local
file in the frontend
directory to specify the backend URL during development:
# frontend/.env.local
NEXT_PUBLIC_BACKEND_URL=http://localhost:5001
NEXT_PUBLIC_
prefix makes this variable accessible in the browser-side code.
IMPORTANT: After creating or modifying the frontend/.env.local
file, you must restart your Next.js development server (npm run dev
) for the changes to take effect.
4. Running the Application
-
Start the Backend Server: Open a terminal, navigate to the
backend
directory, and run:node server.js
You should see
Backend server listening at http://localhost:5001
. -
Start the Frontend Server: Open another terminal, navigate to the
frontend
directory, and run:npm run dev
You should see output indicating the Next.js server is running, typically at
http://localhost:3000
. Remember to restart this if you just created/modified.env.local
. -
Test the Flow:
- Open
http://localhost:3000
in your browser. - Enter your phone number (including country code, e.g., +1...) and click ""Send Code"".
- Check your phone for the 6-digit SMS code (or voice call if SMS fails). You should see logs in the backend terminal indicating the request.
- Enter the received code into the second form and click ""Verify Code"".
- You should see a success message if the code is correct, or an error message otherwise. Check the backend logs for details on success or failure.
- Open
5. Error Handling, Logging, and Retry Mechanisms
- Backend Error Handling:
- We use
try...catch
withasync/await
to handle errors from the Vonage SDK V3+. - We inspect
error.response
(specificallyerror.response.status
anderror.response.data
) to map Vonage API errors (like 400, 410, 429) to user-friendly messages and appropriate HTTP status codes. - Production Enhancement: Use a dedicated logging library (like
Winston
orPino
) instead ofconsole.log
/console.error
for structured logging (JSON format), different log levels (info, warn, error), and outputting logs to files or external services. - Consult the Vonage Verify API V2 Reference for detailed error responses.
- We use
- Frontend Error Handling:
- The frontend checks
response.ok
and uses the JSON error message from the backend (data.error
). - Display clear, non-technical error messages to the user. Avoid exposing raw error details.
- Guide the user on what to do next (e.g., "check the code", "request a new code").
- The frontend checks
- Retry Mechanisms:
- Vonage Internal Retries: Verify V2 workflows can include retries (e.g., SMS -> Voice). Configure workflows as needed via the Vonage API or Dashboard.
- User-Initiated Retries: The frontend allows the user to effectively retry by going back ("Change Phone Number") or implicitly if they enter the wrong code and try again. You could add an explicit "Resend Code" button which would call
/api/request-verification
again (essential to implement rate limiting). - Backend API Call Retries: For transient network errors when the backend calls Vonage, consider implementing exponential backoff retries (e.g., using libraries like
async-retry
), especially if network reliability is a concern.
requestId
Handling)
6. Database Schema and Data Layer (Secure This simple OTP flow doesn't strictly require a database. However, in a real-world application integrating 2FA into a user system, and for more secure handling of the requestId
:
- User Table: You'd typically have a
users
table. - 2FA Status: Add columns like
is_2fa_enabled
(boolean) andphone_number_for_2fa
to theusers
table. - Secure
requestId
Handling (Recommended): The approach of sendingrequestId
to the client (used in this guide for simplicity) is less secure because the client controls it. A malicious user could potentially try to check codes against differentrequestId
s. The recommended, more secure pattern uses server-side sessions:- User authenticates partially (e.g., logs in with password, or is registering).
- Backend API
/api/request-verification
is called. - Backend stores the received
requestId
in the user's server-side session (e.g., usingexpress-session
backed by Redis or a database store). Do NOT send therequestId
back to the client. - Frontend shows the code input form (it doesn't know or need the
requestId
). - Frontend POSTs only the
code
to/api/check-verification
. - Backend retrieves the
requestId
from the user's current session. - Backend calls
vonage.verify.check
using the sessionrequestId
and the submittedcode
. - If successful, mark the session as fully authenticated (2FA passed).
This prevents the client from manipulating the
requestId
during the check step.
- Migrations: Use a migration tool (like
knex migrations
or Prisma Migrate) if adding 2FA fields to your database schema.
7. Adding Security Features
- Input Validation & Sanitization:
- Backend: Use a library like
express-validator
to rigorously validate inputs (phoneNumber
format/length using E.164 checks,code
format/length,requestId
format if applicable). Sanitize inputs where necessary. - Frontend: Basic HTML5 validation (
required
,type="tel"
,pattern
) provides initial checks but never rely solely on frontend validation.
- Backend: Use a library like
- Rate Limiting: CRITICAL for OTP endpoints.
- Apply rate limiting to
/api/request-verification
(per user ID if logged in, or per IP address) to prevent SMS pumping fraud and abuse. - Apply stricter rate limiting to
/api/check-verification
(per user ID/session, or per IP) to prevent brute-force guessing of codes. - Use a library like
express-rate-limit
.
// Example in backend/server.js const rateLimit = require('express-rate-limit'); const requestLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // Limit each IP/user to 5 requests per windowMs message: 'Too many verification requests, please try again after 15 minutes', // keyGenerator: (req) => req.user.id || req.ip, // Example: Prefer user ID if available }); const checkLimiter = rateLimit({ windowMs: 5 * 60 * 1000, // 5 minutes max: 5, // Limit each IP/user to 5 check attempts per windowMs message: 'Too many verification attempts, please try again later.', // keyGenerator: (req) => req.user.id || req.ip, // Example: Prefer user ID if available }); app.post('/api/request-verification', requestLimiter, async (req, res) => { /* ... handler ... */ }); app.post('/api/check-verification', checkLimiter, async (req, res) => { /* ... handler ... */ });
- Apply rate limiting to
- HTTPS: Always use HTTPS in production to encrypt data in transit. Configure your hosting environment (Vercel, Heroku, Nginx, etc.) to enforce HTTPS.
- API Key Security: Never commit API keys or secrets directly into your code or version control. Use
.env
files (added to.gitignore
as shown in setup) and configure environment variables securely in your deployment environment. - CORS Configuration: In production, configure
cors
restrictively to only allow requests from your specific frontend domain(s).// Example restrictive CORS for production in backend/server.js const allowedOrigins = ['https://your-frontend-domain.com']; // Replace const corsOptions = { origin: function (origin, callback) { if (!origin || allowedOrigins.indexOf(origin) !== -1) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); } }, optionsSuccessStatus: 200 // For legacy browser support }; app.use(cors(corsOptions)); // Apply restrictive CORS options
- Secure Session Management: If implementing the recommended server-side
requestId
handling (Section 6), use secure session middleware (express-session
) with:- A strong, randomly generated secret.
- Secure cookie settings (
httpOnly: true
,secure: true
(requires HTTPS),sameSite: 'Lax'
or'Strict'
). - A suitable session store for production (like Redis or a database store, not the default memory store).
- Vonage Security Features: Explore Vonage Verify V2 features like fraud detection, workflow customization (e.g., SMS then Voice fallback), and Silent Authentication for enhanced security and user experience.