code examples
code examples
Twilio Verify API with Node.js and Fastify: Implement OTP 2FA in 15 Minutes
Learn how to implement two-factor authentication using Twilio Verify API, Node.js, and Fastify. Complete tutorial with SMS OTP verification, phone number validation, error handling, and production-ready security best practices.
Learn how to implement secure two-factor authentication (2FA) using Twilio Verify API with Node.js and Fastify. This step-by-step tutorial shows you how to send SMS verification codes, validate phone numbers, and verify OTP codes to add a critical security layer to your application.
Build a web application with two core functions: request an OTP sent via SMS to a user's phone number, then verify the OTP they enter. This ensures users possess the registered phone number during login or sensitive operations.
What You'll Build: Twilio OTP Authentication System
What We're Building:
A Node.js web application using the Fastify framework that integrates with the Twilio Verify API to:
- Accept a user's phone number via an HTML form.
- Request Twilio 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 Twilio 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 (supports versions 14, 16, 18, 20, and LTS 22 as of 2025).1
- Fastify: A high-performance, low-overhead Node.js web framework (v5.6.x as of 2025). Chosen for its speed, extensibility, and developer experience.2
- Twilio Verify API: A service for sending and checking verification codes via SMS, voice, email, WhatsApp, and other channels. Simplifies the complex logic of OTP delivery and management.3
twilio: The official Twilio Node.js SDK for interacting with the API.1@fastify/view&ejs: For server-side rendering of simple HTML templates.@fastify/formbody: To parseapplication/x-www-form-urlencodedrequest 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(Twilio Verify API);
C -- 3. Sends OTP via SMS --> D(User's Phone);
D -- 4. User Enters OTP --> A;
A -- 5. Submits OTP & Verification SID --> B;
B -- 6. Sends Check Request (Code, SID) --> C;
C -- 7. Returns Verification Status --> B;
B -- 8. Displays Success/Failure --> A;Prerequisites:
- Install Node.js (LTS version 20 or 22 recommended as of 2025) and npm (or yarn).
- Sign up at https://www.twilio.com/try-twilio if you don't have a Twilio account with an active Verify Service.
- Locate your Twilio Account SID and Auth Token on your Twilio Console dashboard.
- Create a Twilio Verify Service SID in the Twilio Console under Verify > Services.
- Install a text editor or IDE (e.g., VS Code).
- Access a terminal or command prompt.
Security Considerations (2025 NIST Guidelines):
NIST SP 800-63B classifies SMS-based authentication as "RESTRICTED" due to vulnerabilities including SIM swapping and number porting attacks.4 Organizations using SMS for 2FA should:
- Offer alternative non-SMS authenticators (TOTP, hardware tokens, passkeys)
- Monitor for risk indicators (device swap, SIM change, number porting)
- Provide users with meaningful notice about security limitations
- Develop migration plans toward more secure authentication methods
Expected Outcome:
A functional web application running locally that demonstrates the complete Twilio OTP request and verification flow using Fastify.
1. Setting up Your Node.js Project with Twilio
Initialize your Node.js project and install the necessary dependencies.
-
Create Project Directory:
Open your terminal, create a new directory for the project, then navigate into it.
bashmkdir fastify-twilio-otp cd fastify-twilio-otp -
Initialize Node.js Project:
Create a
package.jsonfile to manage dependencies and project metadata.bashnpm init -y -
Install Dependencies:
Install Fastify, the Twilio SDK, template rendering, form body parsing, environment variable management, and phone number validation.
bashnpm install fastify twilio @fastify/view ejs @fastify/formbody dotenv libphonenumber-js -
Install Development Dependency (Optional but Recommended):
Install
nodemonto automatically restart the server during development when file changes are detected.bashnpm install --save-dev nodemon -
Create Project Structure:
Set up a basic structure for configuration, server logic, and views.
bashmkdir 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 .gitignoresrc/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:Add
node_modulesand.envto prevent committing them.ini# .gitignore node_modules .env *.log -
Configure Environment Variables:
Add placeholders to
.env.exampleand add your actual credentials to.env.ini# .env.example TWILIO_ACCOUNT_SID=YOUR_ACCOUNT_SID TWILIO_AUTH_TOKEN=YOUR_AUTH_TOKEN TWILIO_VERIFY_SERVICE_SID=YOUR_VERIFY_SERVICE_SID TWILIO_BRAND_NAME="Awesome App" # Optional: Brand name shown in SMS message PORT=3000Now open
.envand replace the placeholders with your actual Twilio credentials from the Twilio Console. CustomizeTWILIO_BRAND_NAMEif desired.ini# .env - DO NOT COMMIT THIS FILE TWILIO_ACCOUNT_SID=xxxxxxxx TWILIO_AUTH_TOKEN=yyyyyyyyyyyyyyyy TWILIO_VERIFY_SERVICE_SID=zzzzzzzz TWILIO_BRAND_NAME="My Secure App" PORT=3000 -
Add
package.jsonScripts:Open
package.jsonand add scripts for starting the server normally and withnodemon.json{ "name": "fastify-twilio-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": "^8.0.0", "@fastify/view": "^10.0.0", "twilio": "^5.3.0", "dotenv": "^16.4.5", "ejs": "^3.1.10", "fastify": "^5.6.0", "libphonenumber-js": "^1.11.14" }, "devDependencies": { "nodemon": "^3.1.9" } }(Note: These versions are current as of 2025. Fastify v5.6.x includes performance improvements and better TypeScript support. Ensure the versions in your
package.jsonreflect whatnpm installadded, as newer versions may be available.)5
Now the basic project structure and dependencies are set up.
2. Implementing the Twilio Verify API Integration
Build the Fastify server and integrate the Twilio Verify logic.
-
Basic Server Setup (
src/server.js):Initialize Fastify, load environment variables, register necessary plugins, and import the phone number library.
javascript// 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 { parsePhoneNumberFromString } = require('libphonenumber-js'); // Import phone number library // Load environment variables require('dotenv').config(); // Validate essential environment variables if (!process.env.TWILIO_ACCOUNT_SID || !process.env.TWILIO_AUTH_TOKEN || !process.env.TWILIO_VERIFY_SERVICE_SID) { console.error('Error: TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_VERIFY_SERVICE_SID must be set in .env file'); process.exit(1); } const PORT = process.env.PORT || 3000; const TWILIO_BRAND_NAME = process.env.TWILIO_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 Twilio SDK const twilio = require('twilio')(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN); // --- 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();- Import necessary modules, including
parsePhoneNumberFromString. dotenv.config()loads variables from.env.- Validate that essential Twilio keys are present.
- Initialize Fastify with logging enabled.
- Register
@fastify/formbodyto parse POST request bodies from HTML forms. - Register
@fastify/viewwithejsas the templating engine, pointing to theviewsdirectory. - Instantiate the Twilio SDK using credentials from environment variables.
- Define and call a basic server start function with error handling.
- Import necessary modules, including
-
Create HTML Templates:
Populate the
.ejsfiles with simple HTML forms.-
views/request-form.ejs: Form to enter the phone number.html<!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.html<!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"> <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.html<!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.html<!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 the
start()function call insrc/server.js.javascript// 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 Twilio 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 Twilio API call const numberE164 = phoneNumber.format('E.164'); // e.g., +14155551212 // Twilio Verify API often expects the number *without* the leading '+' const numberForTwilio = numberE164.startsWith('+') ? numberE164.substring(1) : numberE164; fastify.log.info(`Requesting OTP for validated number (E.164): ${numberE164}`); try { const result = await twilio.verify.v2.services(process.env.TWILIO_VERIFY_SERVICE_SID).verifications.create({ to: numberForTwilio, // Send number without '+' channel: 'sms', brand: TWILIO_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(`Twilio Verify request sent, Verification SID: ${result.sid}`); // Check the Twilio API response status explicitly // Status 'pending' means Twilio accepted the request. Non-pending indicates an issue. if (result.status === 'pending') { // Render the verification form, passing the verification_sid return reply.view('verify-form', { requestId: result.sid, error: null }); } else { // If Twilio returns an error status initially fastify.log.error(`Twilio Verify start error for number ${numberForTwilio}: ${result.error_message} (Status: ${result.status})`); // Check for specific errors like invalid number format reported by Twilio itself let userErrorMessage = `Could not initiate verification: ${result.error_message || '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(`Twilio 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 Twilio 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 Verification SID: ${requestId}`); try { const result = await twilio.verify.v2.services(process.env.TWILIO_VERIFY_SERVICE_SID).verificationChecks.create({ verificationSid: requestId, code: code }); fastify.log.info(`Twilio Verify check result: Status ${result.status}`); // Status 'approved' means successful verification if (result.status === 'approved') { fastify.log.info(`Verification successful for Verification SID: ${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 Twilio error statuses fastify.log.warn(`Verification failed for Verification SID: ${requestId}. Status: ${result.status}, Error: ${result.error_message}`); let errorMessage = `Verification failed: ${result.error_message || '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://www.twilio.com/docs/verify/api/verification-checks#status return reply.view('verify-form', { requestId: requestId, error: errorMessage }); } } catch (error) { fastify.log.error(`Twilio 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
numberfrom the form body. - Uses
libphonenumber-jsto 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 Twilio Verify. - Calls
twilio.verify.v2.services(process.env.TWILIO_VERIFY_SERVICE_SID).verifications.create()with the validated number and brand name. Explicitly requests a 6-digit code (code_length: '6'). - Checks
result.status. Ifpending, rendersverify-form.ejs, passing theresult.sid. If non-pending, shows an error on the initial form, potentially more specific if the status code is known (like '3' for invalid number). - Includes
try...catchfor SDK/network errors.
- Retrieves the
POST /verify-otp:- Retrieves the
codeandrequestIdfrom the form body. Performs basic format validation on the code. - Calls
twilio.verify.v2.services(process.env.TWILIO_VERIFY_SERVICE_SID).verificationChecks.create()with therequestIdandcode. - Checks
result.status. Ifapproved, renderssuccess.ejs. If non-approved, rendersverify-form.ejsagain with a user-friendly error message derived fromresult.error_messageand common status codes. - Includes
try...catchfor SDK/network errors.
- Retrieves the
setErrorHandler: A basic global error handler catches unhandled exceptions and displays a generic error page.
-
Run the Application:
bashnpm run devOpen 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 RESTful API for OTP Verification
While this guide focuses on server-rendered HTML, the core Twilio 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 numberForTwilio = phoneNumber.format('E.164').substring(1); // Remove leading '+'
try {
const result = await twilio.verify.v2.services(process.env.TWILIO_VERIFY_SERVICE_SID).verifications.create({ number: numberForTwilio, brand: TWILIO_BRAND_NAME, code_length: '6' });
if (result.status === 'pending') {
reply.send({ requestId: result.sid });
} else {
fastify.log.error(`API Twilio Verify start error: ${result.error_message} (Status: ${result.status})`);
reply.code(400).send({ error: `Could not initiate verification: ${result.error_message || 'Unknown error'}` });
}
} catch (error) {
fastify.log.error(`API Twilio 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 twilio.verify.v2.services(process.env.TWILIO_VERIFY_SERVICE_SID).verificationChecks.create({ verificationSid: requestId, code: code });
if (result.status === 'approved') {
// In a real API, likely issue a JWT or session token here
reply.send({ status: 'verified' });
} else {
fastify.log.warn(`API Verification failed for Verification SID: ${requestId}. Status: ${result.status}, Error: ${result.error_message}`);
reply.code(400).send({ error: `Verification failed: ${result.error_message || 'Invalid code or request expired.'}` });
}
} catch (error) {
fastify.log.error(`API Twilio 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. Configuring Twilio Verify Service Credentials
-
API Credentials:
TWILIO_ACCOUNT_SID: Your public API key from the Twilio Console.TWILIO_AUTH_TOKEN: Your private API secret from the Twilio Console. Treat this like a password.- How to Obtain:
- Log in to your Twilio Console.
- The Console displays your API Key and Secret prominently on the main dashboard page under "Account Info".
- Copy these values directly into your
.envfile.
-
Environment Variables:
TWILIO_ACCOUNT_SID: (String) Required for authentication. Format: Typically 34 alphanumeric characters.TWILIO_AUTH_TOKEN: (String) Required for authentication. Format: Typically 32 alphanumeric characters.TWILIO_VERIFY_SERVICE_SID: (String) Required for specifying the Verify Service. Format: Typically 34 alphanumeric characters.TWILIO_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
.envanddotenvkeeps credentials out of your source code. Ensure.envis listed in your.gitignorefile. In production environments (like Heroku, AWS, etc.), use the platform's mechanism for setting environment variables securely – do not deploy.envfiles. -
Fallback Mechanisms: The Twilio 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 and Logging Best Practices
-
Error Handling Strategy:
- Specific Twilio Errors: Check the
statusanderror_messagefields in the Twilio API responses (verify.createandverificationChecks.create). Provide user-friendly messages based on common statuses (e.g., invalid code, expired request). Link to Twilio Verify API Errors. Our code examples show basic mapping for common errors. - SDK/Network Errors: Use
try...catchblocks around Twilio 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 Twilio API using
libphonenumber-jsand basic checks. - Global Handler: Use Fastify's
setErrorHandlerfor unexpected/unhandled errors to prevent crashes and provide a generic error page/response.
- Specific Twilio 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), Twilio response status (success/failure with status code and error message), 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-formthat navigates back or triggers/request-otpagain (with rate limiting). - API Call Retries: Retrying failed
verify.createorverificationChecks.createcalls immediately is usually not recommended, as the issue might be persistent (e.g., invalid API key, Twilio 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. Twilio 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 Integration for User Authentication
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
userstable. 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_idtemporarily if needed for specific flows, but Twilio 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 theuserstable. - Upon successful OTP verification (
/verify-otpsuccess), update the corresponding user record: setphone_verified_atto the current time and potentiallytwo_factor_enabledto 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. Production Security Features
-
Input Validation:
- Phone Numbers: Use
libphonenumber-jsfor robust international phone number validation and formatting (as implemented in Section 2). Always validate before sending to Twilio. - OTP Codes: Validate that the code format matches expectations (e.g., 4-6 digits, as shown in
/verify-otproute). - Request IDs: Ensure they are present and seem well-formed (though validating their actual existence relies on the Twilio 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).
javascript// 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
Frequently Asked Questions About Twilio OTP Implementation
How do I get started with Twilio Verify API for OTP?
Sign up for a Twilio account at https://www.twilio.com/try-twilio, obtain your Account SID and Auth Token from the Console dashboard, then create a Verify Service under Verify > Services. Use these credentials in your .env file to authenticate API requests.
What is the difference between Twilio Verify API and SMS API?
Twilio Verify API is purpose-built for OTP and 2FA workflows, handling code generation, delivery retries, rate limiting, and expiration automatically. The SMS API requires you to manually implement these features. Verify API supports multiple channels (SMS, voice, email, WhatsApp) with automatic fallback.
Why does NIST classify SMS authentication as "RESTRICTED"?
NIST SP 800-63B classifies SMS as RESTRICTED due to vulnerabilities including SIM swapping attacks, number porting exploits, and SS7 protocol weaknesses.4 Organizations should offer alternative authenticators like TOTP apps, hardware tokens, or passkeys, and monitor for risk indicators like device swaps.
How do I validate international phone numbers in Node.js?
Use the libphonenumber-js library to parse and validate phone numbers. Call parsePhoneNumberFromString(number) and check isValid(). Format validated numbers to E.164 standard using format('E.164') before sending to Twilio Verify API. This ensures consistent formatting across all countries.
What is the default OTP code length in Twilio Verify?
Twilio Verify API defaults to 4-digit verification codes. Specify code_length: '6' in the verifications.create() call to use 6-digit codes for enhanced security. Longer codes provide better protection against brute-force attacks while remaining user-friendly.
How do I implement rate limiting for OTP endpoints in Fastify?
Install @fastify/rate-limit and register it with your Fastify instance. Apply route-specific limits using the config.rateLimit option: limit /request-otp to 5 requests per phone number per hour to prevent SMS abuse, and limit /verify-otp to 10 attempts per verification ID per 5 minutes to prevent brute-forcing.
Can I use Twilio Verify API with Fastify v5?
Yes, Twilio Verify API works with Fastify v5.6.x (current as of 2025). The twilio Node.js SDK (v5.3.0+) supports Node.js 14, 16, 18, 20, and LTS 22. Fastify v5 includes performance improvements and better TypeScript support without breaking changes to plugin APIs used in this guide.
What happens if the user doesn't receive the OTP code?
Twilio Verify API automatically handles SMS delivery retries. If delivery fails, the API can fall back to voice calls based on your workflow configuration. Implement a "Resend Code" button that calls /request-otp again with rate limiting. Check Twilio Console logs for delivery status and carrier-specific issues.
Related Tutorials and Resources
- Twilio Verify API Documentation
- Fastify Official Documentation
- NIST SP 800-63B Digital Identity Guidelines
- libphonenumber-js GitHub Repository
- E.164 Phone Number Format Guide
- Twilio Node.js SDK Documentation
- Fastify Rate Limiting Plugin
Footnotes
Frequently Asked Questions
How to implement two-factor authentication with Node.js?
Implement 2FA using the Vonage Verify API with Node.js and the Fastify framework. This involves accepting a user's phone number, sending an OTP via SMS using the Vonage API, and then verifying the user-entered OTP against the Vonage request to enhance login security.
What is the Vonage Verify API used for in Node.js?
The Vonage Verify API simplifies the process of sending and verifying one-time passwords (OTPs) within Node.js applications. It handles the complex logic of OTP delivery and management via SMS, voice, and other channels, enhancing security beyond simple passwords.
Why use Fastify for a Node.js 2FA project?
Fastify is a high-performance Node.js web framework chosen for its speed and developer-friendly experience. Its extensibility makes it ideal for integrating services like the Vonage Verify API, and its low overhead contributes to application efficiency.
When should I add 2FA to my Node.js application?
Two-factor authentication (2FA) should be added to your Node.js application anytime you need to strengthen user authentication beyond relying solely on potentially vulnerable passwords. This is especially important during login and other sensitive actions.
How to install Vonage Server SDK for Node.js?
Install the Vonage Server SDK using npm or yarn with the command: `npm install @vonage/server-sdk`. This SDK allows your Node.js application to interact with the Vonage Verify API for sending and verifying OTPs.
What is libphonenumber-js used for in 2FA?
`libphonenumber-js` provides robust phone number validation and formatting in your 2FA implementation. This ensures phone numbers are in the correct international format before sending OTP requests to Vonage.
What are the Vonage API credentials I need for OTP?
You'll need your Vonage API Key and API Secret, both found on your Vonage Dashboard. These credentials are essential for authenticating with the Vonage API and are used when initializing the Vonage SDK within your application.
How to set environment variables in Node.js with dotenv?
Create a `.env` file in your project root and store sensitive information like API keys there. Install `dotenv` with npm, require it in `server.js` with `require('dotenv').config()`, then access via `process.env.VARIABLE_NAME`.
Why shouldn't I commit the .env file?
The `.env` file contains sensitive data like API keys which should never be exposed publicly. Add `.env` to your `.gitignore` file to prevent it from being accidentally committed to version control.
How to handle Vonage Verify API errors in Node.js?
Check the `status` and `error_text` fields in Vonage API responses. Provide user-friendly error messages based on common status codes like invalid numbers, expired requests, or too many attempts. For SDK or network errors, use `try...catch` blocks and log errors server-side.
What is the role of requestId in Vonage Verify API?
The `requestId` is a unique identifier returned by `vonage.verify.start()`. It's crucial for tracking the verification process. It's passed to `vonage.verify.check()` along with the OTP to verify the user's input.
How to validate phone numbers for Vonage Verify API?
Use the `libphonenumber-js` library to validate international phone numbers before sending them to the Vonage Verify API. Parse the input with `parsePhoneNumberFromString` and check validity with `phoneNumber.isValid()`. Use E.164 formatting for consistency.
How to create a JSON API for Vonage 2FA in Fastify?
Create separate API endpoints (e.g., `/api/otp/request`, `/api/otp/verify`) in your Fastify application. Use `request.body` to handle JSON payloads and send responses with `reply.send()` and appropriate status codes (e.g., 200 OK, 400 Bad Request).
Why is rate limiting important for 2FA?
Rate limiting prevents abuse such as SMS spamming and brute-force attacks on OTP codes. Implement rate limiting on your 2FA routes (`/request-otp`, `/verify-otp`) using Fastify plugins or middleware to limit requests per phone number or IP within a timeframe.