Two-factor authentication (2FA) adds a critical layer of security to applications by verifying a user's identity using a second factor, typically something they possess, like a code sent to their phone. This guide provides a step-by-step walkthrough for implementing One-Time Password (OTP) based 2FA using the Vonage Verify API within a Node.js application built with the Fastify framework.
We will build a simple web application with two core functions: requesting an OTP sent via SMS to a user's phone number and verifying the OTP entered by the user. This enhances security by ensuring the user possesses the registered phone number during login or sensitive operations.
Project Overview and Goals
What We're Building:
A Node.js web application using the Fastify framework that integrates with the Vonage Verify API to:
- Accept a user's phone number via an HTML form.
- Request Vonage to send an OTP (PIN code) to that number via SMS.
- Present a form for the user to enter the received OTP.
- Verify the submitted OTP against the Vonage request.
- Display a success or failure message.
Problem Solved:
This implementation addresses the need for stronger user authentication beyond simple passwords, mitigating risks associated with compromised credentials. It provides a practical example of adding SMS-based OTP verification to any Node.js application.
Technologies Used:
- Node.js: The JavaScript runtime environment.
- Fastify: A high-performance, low-overhead Node.js web framework. Chosen for its speed, extensibility, and developer experience.
- Vonage Verify API: A service for sending and checking verification codes via SMS, voice, and other channels. Simplifies the complex logic of OTP delivery and management.
@vonage/server-sdk
: The official Vonage Node.js SDK for interacting with the API.@fastify/view
&ejs
: For server-side rendering of simple HTML templates.@fastify/formbody
: To parseapplication/x-www-form-urlencoded
request bodies (standard HTML form submissions).dotenv
: To manage environment variables securely.libphonenumber-js
: For robust phone number validation and formatting.
System Architecture:
graph LR
A[User Browser] -- 1. Enters Phone Number --> B(Fastify App);
B -- 2. Sends Verify Request (Number) --> C(Vonage Verify API);
C -- 3. Sends OTP via SMS --> D(User's Phone);
D -- 4. User Enters OTP --> A;
A -- 5. Submits OTP & Request ID --> B;
B -- 6. Sends Check Request (Code, Request ID) --> C;
C -- 7. Returns Verification Status --> B;
B -- 8. Displays Success/Failure --> A;
Prerequisites:
- Node.js (LTS version recommended) and npm (or yarn) installed.
- A Vonage API account. Sign up if you don't have one.
- Your Vonage API Key and API Secret (found on your Vonage dashboard).
- A text editor or IDE (e.g., VS Code).
- A terminal or command prompt.
Expected Outcome:
A functional web application running locally that demonstrates the complete Vonage OTP request and verification flow using Fastify.
1. Setting up the Project
Let's initialize our Node.js project and install the necessary dependencies.
-
Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.
mkdir fastify-vonage-otp cd fastify-vonage-otp
-
Initialize Node.js Project: This creates a
package.json
file to manage dependencies and project metadata.npm init -y
-
Install Dependencies: We need Fastify, the Vonage SDK, template rendering, form body parsing, environment variable management, and phone number validation.
npm install fastify @vonage/server-sdk @fastify/view ejs @fastify/formbody dotenv libphonenumber-js
-
Install Development Dependency (Optional but Recommended):
nodemon
automatically restarts the server during development when file changes are detected.npm install --save-dev nodemon
-
Create Project Structure: Set up a basic structure for configuration, server logic, and views.
mkdir src mkdir views touch src/server.js touch views/request-form.ejs touch views/verify-form.ejs touch views/success.ejs touch views/error.ejs touch .env touch .env.example touch .gitignore
src/server.js
: Main application logic.views/
: Directory for HTML templates..env
: Stores sensitive credentials (API keys). Never commit this file..env.example
: Example structure for.env
(safe to commit)..gitignore
: Specifies files/directories Git should ignore.
-
Configure
.gitignore
: Addnode_modules
and.env
to prevent committing them.# .gitignore node_modules .env *.log
-
Configure Environment Variables: Add placeholders to
.env.example
and your actual credentials to.env
.# .env.example VONAGE_API_KEY=YOUR_API_KEY VONAGE_API_SECRET=YOUR_API_SECRET VONAGE_BRAND_NAME=""Awesome App"" # Optional: Brand name shown in SMS message PORT=3000
Now, open
.env
and replace the placeholders with your actual Vonage API Key and Secret from the Vonage Dashboard. You can also customizeVONAGE_BRAND_NAME
.# .env - DO NOT COMMIT THIS FILE VONAGE_API_KEY=xxxxxxxx VONAGE_API_SECRET=yyyyyyyyyyyyyyyy VONAGE_BRAND_NAME=""My Secure App"" PORT=3000
-
Add
package.json
Scripts: Openpackage.json
and add scripts for starting the server normally and withnodemon
.{ ""name"": ""fastify-vonage-otp"", ""version"": ""1.0.0"", ""description"": """", ""main"": ""src/server.js"", ""scripts"": { ""start"": ""node src/server.js"", ""dev"": ""nodemon src/server.js"", ""test"": ""echo \""Error: no test specified\"" && exit 1"" }, ""keywords"": [], ""author"": """", ""license"": ""ISC"", ""dependencies"": { ""@fastify/formbody"": ""^7.4.0"", ""@fastify/view"": ""^8.2.0"", ""@vonage/server-sdk"": ""^3.11.1"", ""dotenv"": ""^16.3.1"", ""ejs"": ""^3.1.9"", ""fastify"": ""^4.25.2"", ""libphonenumber-js"": ""^1.10.52"" }, ""devDependencies"": { ""nodemon"": ""^3.0.2"" } }
(Note: Ensure the versions in your
package.json
reflect whatnpm install
added)
Now the basic project structure and dependencies are set up.
2. Implementing Core Functionality (Server Logic)
Let's build the Fastify server and integrate the Vonage Verify logic.
-
Basic Server Setup (
src/server.js
): Initialize Fastify, load environment variables, register necessary plugins, and import the phone number library.// src/server.js 'use strict'; const path = require('node:path'); const Fastify = require('fastify'); const fastifyView = require('@fastify/view'); const fastifyFormbody = require('@fastify/formbody'); const ejs = require('ejs'); const { Vonage } = require('@vonage/server-sdk'); const { parsePhoneNumberFromString } = require('libphonenumber-js'); // Import phone number library // Load environment variables require('dotenv').config(); // Validate essential environment variables 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); } const PORT = process.env.PORT || 3000; const VONAGE_BRAND_NAME = process.env.VONAGE_BRAND_NAME || 'MyApp'; // Default brand // Initialize Fastify const fastify = Fastify({ logger: true // Enable basic logging }); // Register Fastify plugins fastify.register(fastifyFormbody); // For parsing form data fastify.register(fastifyView, { engine: { ejs: ejs, }, root: path.join(__dirname, '../views'), // Path to templates viewExt: 'ejs', // Default template extension }); // Initialize Vonage SDK const vonage = new Vonage({ apiKey: process.env.VONAGE_API_KEY, apiSecret: process.env.VONAGE_API_SECRET }); // --- Routes will be added here --- // Start the server const start = async () => { try { await fastify.listen({ port: PORT }); fastify.log.info(`Server listening on port ${PORT}`); } catch (err) { fastify.log.error(err); process.exit(1); } }; start();
- We import necessary modules, including
parsePhoneNumberFromString
. dotenv.config()
loads variables from.env
.- We validate that essential Vonage keys are present.
- Fastify is initialized with logging enabled.
@fastify/formbody
is registered to parse POST request bodies from HTML forms.@fastify/view
is registered withejs
as the templating engine, pointing to theviews
directory.- The Vonage SDK is instantiated using credentials from environment variables.
- A basic server start function with error handling is defined and called.
- We import necessary modules, including
-
Create HTML Templates: Populate the
.ejs
files with simple HTML forms.-
views/request-form.ejs
: Form to enter the phone number.<!DOCTYPE html> <html lang=""en""> <head> <meta charset=""UTF-8""> <meta name=""viewport"" content=""width=device-width, initial-scale=1.0""> <title>Request OTP</title> <style>body { font-family: sans-serif; padding: 20px; } label, input, button { display: block; margin-bottom: 10px; } .error { color: red; margin-top: 15px; }</style> </head> <body> <h1>Enter Your Phone Number</h1> <p>We'll send an OTP code via SMS. Use international format (e.g., +14155551212).</p> <form method=""POST"" action=""/request-otp""> <label for=""number"">Phone Number (e.g., +14155551212):</label> <input type=""tel"" id=""number"" name=""number"" required placeholder=""+14155551212""> <button type=""submit"">Send OTP</button> </form> <% if (typeof error !== 'undefined' && error) { %> <p class=""error"">Error: <%= error %></p> <% } %> </body> </html>
-
views/verify-form.ejs
: Form to enter the received OTP. It includes a hidden field for therequestId
.<!DOCTYPE html> <html lang=""en""> <head> <meta charset=""UTF-8""> <meta name=""viewport"" content=""width=device-width, initial-scale=1.0""> <title>Verify OTP</title> <style>body { font-family: sans-serif; padding: 20px; } label, input, button { display: block; margin-bottom: 10px; } .error { color: red; margin-top: 15px; }</style> </head> <body> <h1>Enter OTP Code</h1> <p>Check your phone for the code.</p> <form method=""POST"" action=""/verify-otp""> <label for=""code"">OTP Code:</label> <input type=""text"" id=""code"" name=""code"" required pattern=""\d{4,6}"" title=""Enter the 4-6 digit code""> <!-- Hidden field to pass the requestId --> <input type=""hidden"" name=""requestId"" value=""<%= requestId %>""> <button type=""submit"">Verify Code</button> </form> <% if (typeof error !== 'undefined' && error) { %> <p class=""error"">Error: <%= error %></p> <% } %> </body> </html>
-
views/success.ejs
: Success message page.<!DOCTYPE html> <html lang=""en""> <head> <meta charset=""UTF-8""> <meta name=""viewport"" content=""width=device-width, initial-scale=1.0""> <title>Success</title> <style>body { font-family: sans-serif; padding: 20px; } .success { color: green; }</style> </head> <body> <h1 class=""success"">Verification Successful!</h1> <p>Your phone number has been verified.</p> <a href=""/"">Start Over</a> </body> </html>
-
views/error.ejs
: Generic error message page.<!DOCTYPE html> <html lang=""en""> <head> <meta charset=""UTF-8""> <meta name=""viewport"" content=""width=device-width, initial-scale=1.0""> <title>Error</title> <style>body { font-family: sans-serif; padding: 20px; } .error { color: red; }</style> </head> <body> <h1 class=""error"">Verification Failed</h1> <p>An error occurred: <%= message %></p> <p><a href=""/"">Try Again</a></p> </body> </html>
-
-
Implement Routes (
src/server.js
): Add the Fastify routes to handle the OTP flow. Place this code before thestart()
function call insrc/server.js
.// src/server.js (add this section) // --- Routes --- // Route to display the initial phone number form fastify.get('/', (request, reply) => { reply.view('request-form', { error: null }); // Pass null error initially }); // Route to handle the phone number submission and request OTP from Vonage fastify.post('/request-otp', async (request, reply) => { const { number } = request.body; // Validate phone number using libphonenumber-js const phoneNumber = parsePhoneNumberFromString(number); // No default country needed if expecting international format if (!phoneNumber || !phoneNumber.isValid()) { fastify.log.warn(`Invalid phone number format received: ${number}`); return reply.view('request-form', { error: 'Invalid phone number format. Please use international format (e.g., +14155551212).' }); } // Use the E.164 format for the Vonage API call const numberE164 = phoneNumber.format('E.164'); // e.g., +14155551212 // Vonage Verify API often expects the number *without* the leading '+' const numberForVonage = numberE164.startsWith('+') ? numberE164.substring(1) : numberE164; fastify.log.info(`Requesting OTP for validated number (E.164): ${numberE164}`); try { const result = await vonage.verify.start({ number: numberForVonage, // Send number without '+' brand: VONAGE_BRAND_NAME, code_length: '6' // Optional: Specify 4 or 6 digits (default is 4) // workflow_id: 1 // Optional: Specify workflow (SMS -> TTS -> TTS is default) }); fastify.log.info(`Vonage Verify request sent, Request ID: ${result.request_id}`); // Important: Check the Vonage API response status explicitly // Status '0' means the request was accepted by Vonage. Non-zero indicates an issue. if (result.status === '0') { // Render the verification form, passing the request_id return reply.view('verify-form', { requestId: result.request_id, error: null }); } else { // If Vonage returns an error status initially fastify.log.error(`Vonage Verify start error for number ${numberForVonage}: ${result.error_text} (Status: ${result.status})`); // Check for specific errors like invalid number format reported by Vonage itself let userErrorMessage = `Could not initiate verification: ${result.error_text || 'Unknown error from verification service.'}`; if (result.status === '3') { // '3' often means Invalid number userErrorMessage = 'The phone number format was rejected by the verification service. Please check the number and format.'; } return reply.view('request-form', { error: userErrorMessage }); } } catch (error) { fastify.log.error(`Vonage SDK error during verify start: ${error.message}`); // Handle potential SDK errors (network issues, config errors etc.) return reply.view('request-form', { error: 'An unexpected error occurred while contacting the verification service. Please try again later.' }); } }); // Route to handle the OTP submission and verify it with Vonage fastify.post('/verify-otp', async (request, reply) => { const { code, requestId } = request.body; // Basic validation for code and requestId presence if (!code || !requestId || !/^\d{4,6}$/.test(code)) { fastify.log.warn(`Invalid code format or missing requestId. Code: ${code}, RequestID: ${requestId}`); // Attempt to render verify form again if requestId is available, else show generic error if (requestId) { return reply.view('verify-form', { requestId: requestId, error: 'Invalid or missing code. Please enter the 4-6 digit code received.' }); } else { // If requestId is missing, we can't really proceed with the verify form return reply.code(400).view('error', { message: 'Missing verification session information. Please start over.' }); } } fastify.log.info(`Verifying OTP for Request ID: ${requestId}`); try { const result = await vonage.verify.check(requestId, code); fastify.log.info(`Vonage Verify check result: Status ${result.status}`); // Status '0' means successful verification if (result.status === '0') { fastify.log.info(`Verification successful for Request ID: ${requestId}`); // In a real app, you would now associate the verified number with the user account, // set a session flag, issue a JWT, etc. return reply.view('success'); } else { // Handle specific Vonage error statuses fastify.log.warn(`Verification failed for Request ID: ${requestId}. Status: ${result.status}, Error: ${result.error_text}`); let errorMessage = `Verification failed: ${result.error_text || 'Invalid code or request expired.'}`; // Provide more user-friendly messages based on status codes if needed if (result.status === '6') { // Invalid Code errorMessage = 'The code you entered was incorrect. Please try again.'; } else if (result.status === '16') { // Request Not Found / Expired errorMessage = 'The verification request has expired or is invalid. Please request a new code.'; } else if (result.status === '17') { // Too Many Attempts errorMessage = 'Too many incorrect attempts. Please request a new code.'; } // See: https://developer.vonage.com/en/api/verify#verify-check-errors return reply.view('verify-form', { requestId: requestId, error: errorMessage }); } } catch (error) { fastify.log.error(`Vonage SDK error during verify check: ${error.message}`); // Handle potential SDK errors return reply.view('verify-form', { requestId: requestId, error: 'An unexpected error occurred while checking the code. Please try again later.' }); } }); // Generic Error Handler (Optional but recommended) fastify.setErrorHandler(function (error, request, reply) { fastify.log.error(`Unhandled error: ${error.message}\n${error.stack}`); // Send generic error response if (!reply.sent) { // Check if a response hasn't already been sent reply.code(500).view('error', { message: 'An internal server error occurred.' }); } }); // --- End of Routes ---
GET /
: Renders the initialrequest-form.ejs
.POST /request-otp
:- Retrieves the
number
from the form body. - Uses
libphonenumber-js
to parse and validate the number. - If invalid, shows an error on the
request-form
. - Formats the valid number to E.164 and removes the leading
+
as often expected by Vonage Verify. - Calls
vonage.verify.start()
with the validated number and brand name. We explicitly request a 6-digit code (code_length: '6'
). - Crucially, it checks
result.status
. If0
, it rendersverify-form.ejs
, passing theresult.request_id
. If non-zero, it shows an error on the initial form, potentially more specific if the status code is known (like '3' for invalid number). - Includes
try...catch
for SDK/network errors.
- Retrieves the
POST /verify-otp
:- Retrieves the
code
andrequestId
from the form body. Performs basic format validation on the code. - Calls
vonage.verify.check()
with therequestId
andcode
. - Checks
result.status
. If0
, renderssuccess.ejs
. If non-zero, rendersverify-form.ejs
again with a user-friendly error message derived fromresult.error_text
and common status codes. - Includes
try...catch
for SDK/network errors.
- Retrieves the
setErrorHandler
: A basic global error handler catches unhandled exceptions and displays a generic error page.
-
Run the Application:
npm run dev
Open your browser and navigate to
http://localhost:3000
(or the port you configured). You should see the form to enter your phone number.
3. Building a Complete API Layer (Conceptual)
While this guide focuses on server-rendered HTML, the core Vonage logic can easily be exposed via a JSON API.
Example API Endpoints:
POST /api/otp/request
- Request Body (JSON):
{ ""phoneNumber"": ""+14155551212"" }
- Response (Success - 200 OK):
{ ""requestId"": ""a1b2c3d4e5f6..."" }
- Response (Error - 400/500):
{ ""error"": ""Invalid phone number format"" }
or{ ""error"": ""Failed to initiate verification"" }
- Request Body (JSON):
POST /api/otp/verify
- Request Body (JSON):
{ ""requestId"": ""a1b2c3d4e5f6..."", ""code"": ""123456"" }
- Response (Success - 200 OK):
{ ""status"": ""verified"" }
- Response (Error - 400 Bad Request):
{ ""error"": ""Invalid code or request expired."" }
- Response (Error - 500):
{ ""error"": ""Verification check failed"" }
- Request Body (JSON):
Implementation Sketch (in src/server.js
):
// Example API route for requesting OTP
fastify.post('/api/otp/request', async (request, reply) => {
const { phoneNumber: rawPhoneNumber } = request.body;
// Use libphonenumber-js for validation
const phoneNumber = parsePhoneNumberFromString(rawPhoneNumber);
if (!phoneNumber || !phoneNumber.isValid()) {
return reply.code(400).send({ error: 'Invalid phoneNumber format. Use E.164 format (e.g., +14155551212).' });
}
const numberForVonage = phoneNumber.format('E.164').substring(1); // Remove leading '+'
try {
const result = await vonage.verify.start({ number: numberForVonage, brand: VONAGE_BRAND_NAME, code_length: '6' });
if (result.status === '0') {
reply.send({ requestId: result.request_id });
} else {
fastify.log.error(`API Vonage Verify start error: ${result.error_text} (Status: ${result.status})`);
reply.code(400).send({ error: `Could not initiate verification: ${result.error_text || 'Unknown error'}` });
}
} catch (error) {
fastify.log.error(`API Vonage SDK error during verify start: ${error.message}`);
reply.code(500).send({ error: 'Verification service error' });
}
});
// Example API route for verifying OTP
fastify.post('/api/otp/verify', async (request, reply) => {
const { requestId, code } = request.body;
// Add input validation (e.g., using JSON Schema for routes is better)
if (!requestId || !code || !/^\d{4,6}$/.test(code)) {
return reply.code(400).send({ error: 'Missing or invalid requestId or code (must be 4-6 digits)' });
}
try {
const result = await vonage.verify.check(requestId, code);
if (result.status === '0') {
// In a real API, likely issue a JWT or session token here
reply.send({ status: 'verified' });
} else {
fastify.log.warn(`API Verification failed for Request ID: ${requestId}. Status: ${result.status}, Error: ${result.error_text}`);
reply.code(400).send({ error: `Verification failed: ${result.error_text || 'Invalid code or request expired.'}` });
}
} catch (error) {
fastify.log.error(`API Vonage SDK error during verify check: ${error.message}`);
reply.code(500).send({ error: 'Verification check failed' });
}
});
Testing API Endpoints (using curl
):
# Request OTP (Use E.164 format with +)
curl -X POST http://localhost:3000/api/otp/request \
-H ""Content-Type: application/json"" \
-d '{""phoneNumber"": ""+1YOUR_PHONE_NUMBER_E164""}'
# Verify OTP (replace with actual requestId and code)
curl -X POST http://localhost:3000/api/otp/verify \
-H ""Content-Type: application/json"" \
-d '{""requestId"": ""YOUR_REQUEST_ID"", ""code"": ""YOUR_RECEIVED_CODE""}'
4. Integrating with Vonage (Configuration Details)
-
API Credentials:
VONAGE_API_KEY
: Your public API key from the Vonage Dashboard.VONAGE_API_SECRET
: Your private API secret from the Vonage Dashboard. Treat this like a password.- How to Obtain:
- Log in to your Vonage API Dashboard.
- Your API Key and Secret are displayed prominently on the main dashboard page under ""API settings"".
- Copy these values directly into your
.env
file.
-
Environment Variables:
VONAGE_API_KEY
: (String) Required for authentication. Format: Typically 8 alphanumeric characters.VONAGE_API_SECRET
: (String) Required for authentication. Format: Typically 16 alphanumeric characters.VONAGE_BRAND_NAME
: (String, Optional) The name displayed in the SMS message (e.g., ""Your code from [Brand Name] is...""). Max 11 alphanumeric characters or 16 digits for numeric sender ID. Defaults to 'MyApp' in our code if not set.PORT
: (Number, Optional) The port the Fastify server listens on. Defaults to 3000.
-
Secure Storage: Using
.env
anddotenv
keeps credentials out of your source code. Ensure.env
is listed in your.gitignore
file. In production environments (like Heroku, AWS, etc.), use the platform's mechanism for setting environment variables securely – do not deploy.env
files. -
Fallback Mechanisms: The Vonage Verify API itself handles retries and channel fallbacks (e.g., SMS -> Text-to-Speech call) based on the chosen
workflow_id
(default is workflow 1). You generally don't need to implement complex fallback logic on the client-side for delivery itself, but robust application-level error handling (as shown in the route examples) is essential.
5. Error Handling, Logging, and Retries
-
Error Handling Strategy:
- Specific Vonage Errors: Check the
status
anderror_text
fields in the Vonage API responses (verify.start
andverify.check
). Provide user-friendly messages based on common statuses (e.g., invalid code, expired request). Link to Vonage Verify API Errors. Our code examples show basic mapping for common errors. - SDK/Network Errors: Use
try...catch
blocks around Vonage SDK calls to handle network issues, timeouts, or configuration errors. Log these errors server-side and provide generic user messages. - Validation Errors: Handle invalid user input (e.g., bad phone number format, missing/invalid code) before calling the Vonage API using
libphonenumber-js
and basic checks. - Global Handler: Use Fastify's
setErrorHandler
for unexpected/unhandled errors to prevent crashes and provide a generic error page/response.
- Specific Vonage Errors: Check the
-
Logging:
- Fastify's built-in logger (
fastify.log
) provides basic request logging and methods likeinfo
,warn
,error
. - Log key events: OTP request initiation (with validated number), Vonage response status (success/failure with status code and error text), verification attempt (with request ID), verification result.
- Log validation failures (e.g., invalid phone number attempts).
- In production, consider using a more robust logger (like Pino, which Fastify uses internally, or Winston) with structured logging (JSON format) and configurable log levels (e.g., INFO for production, DEBUG for development). Send logs to a centralized logging service (e.g., Datadog, Logstash, CloudWatch).
- Fastify's built-in logger (
-
Retry Mechanisms (Application Level):
- User Retries: Allow the user to request a new code if they didn't receive the first one (potentially after a delay). This is implicitly handled by allowing them back to the first form (
/
). Consider adding a ""Resend Code"" button on theverify-form
that navigates back or triggers/request-otp
again (with rate limiting). - API Call Retries: Retrying failed
verify.start
orverify.check
calls immediately is usually not recommended, as the issue might be persistent (e.g., invalid API key, Vonage outage, invalid number). Log the error and inform the user. If temporary network issues are suspected, a cautious retry with exponential backoff could be considered for specific error types, but often informing the user to try again later is safer. Vonage handles SMS delivery retries internally.
- User Retries: Allow the user to request a new code if they didn't receive the first one (potentially after a delay). This is implicitly handled by allowing them back to the first form (
6. Database Schema and Data Layer (Conceptual)
While this guide doesn't implement a database, in a real-world application, you would integrate this OTP flow with your user management system.
-
Schema: You'd typically have a
users
table. You might add fields like:phone_number
(VARCHAR, UNIQUE) - Store in E.164 format.phone_verified_at
(TIMESTAMP, NULLABLE)two_factor_enabled
(BOOLEAN, DEFAULT FALSE)- (Optionally) Store the
last_verify_request_id
temporarily if needed for specific flows, but Vonage manages the core state.
-
Data Layer:
- When a user registers or adds a phone number, validate it using
libphonenumber-js
, store the E.164 format in theusers
table. - Upon successful OTP verification (
/verify-otp
success), update the corresponding user record: setphone_verified_at
to the current time and potentiallytwo_factor_enabled
to true. - Use an ORM (like Prisma, TypeORM, Sequelize) or a query builder (like Knex.js) to interact with the database safely.
- When a user registers or adds a phone number, validate it using
7. Adding Security Features
-
Input Validation:
- Phone Numbers: Use
libphonenumber-js
for robust international phone number validation and formatting (as implemented in Section 2). Always validate before sending to Vonage. - OTP Codes: Validate that the code format matches expectations (e.g., 4-6 digits, as shown in
/verify-otp
route). - Request IDs: Ensure they are present and seem well-formed (though validating their actual existence relies on the Vonage check).
- Use Fastify's built-in JSON Schema validation for API routes (
/api/*
) for stronger type and format enforcement.
- Phone Numbers: Use
-
Rate Limiting: Crucial to prevent abuse (SMS spamming/toll fraud) and brute-force attacks.
- Use
@fastify/rate-limit
. Apply limits to both/request-otp
(prevent spamming SMS) and/verify-otp
(prevent brute-forcing codes). Also apply to API equivalents. - Configure reasonable limits (e.g., 5 requests per phone number per hour, 10 verification attempts per request ID per 5 minutes).
// Example Rate Limiting Setup (in src/server.js) const fastifyRateLimit = require('@fastify/rate-limit'); async function setupRateLimiting(fastifyInstance) { await fastifyInstance.register(fastifyRateLimit, { max: 100, // Default max requests per windowMs timeWindow: '1 minute', // Example specific limits (apply within route config) // keyGenerator: function (request) { /* ... */ }, // Key by IP, phone number, etc. // allowList: [], // etc. }); // Apply specific limits in route options, e.g.: // fastify.post('/request-otp', { config: { rateLimit: { max: 5, timeWindow: '1 hour', keyGenerator: ... } } }, async (req, reply) => { ... }); // fastify.post('/verify-otp', { config: { rateLimit: { max: 10, timeWindow: '5 minutes', keyGenerator: ... } } }, async (req, reply) => { ... }); } // Call setupRateLimiting(fastify) after initializing fastify // Note: Detailed implementation requires careful key generation (e.g., based on phone number for /request-otp, // or requestId/IP for /verify-otp) and applying limits within route definitions.
- Use