code examples
code examples
Build SMS OTP 2FA with MessageBird, Fastify & Node.js [2025 Tutorial]
Complete guide to implementing two-factor authentication with MessageBird Verify API and Fastify. Learn OTP generation, SMS delivery, token verification, and production-ready security patterns.
Build Secure OTP Two-Factor Authentication with MessageBird, Fastify, and Node.js
Build a secure MessageBird OTP (One-Time Password) Two-Factor Authentication flow in Node.js using Fastify and the MessageBird Verify API for SMS delivery. This guide shows you how to create a Fastify 2FA application that sends OTP codes via SMS and verifies user input, adding a crucial security layer to your Node.js SMS authentication process.
Two-factor authentication enhances your application security beyond traditional username/password logins. It protects against account takeovers from compromised passwords by requiring users to possess a secondary factor – typically their mobile phone – to verify their identity. You'll use MessageBird's Verify API, which simplifies OTP generation, delivery, and verification. Fastify provides the high-performance, low-overhead web framework for building your backend service efficiently.
Prerequisites
Before you begin, ensure you have:
- Node.js version 18.0 or higher (LTS recommended)
- npm version 9.0 or higher
- A MessageBird account with a live API key (create one at dashboard.messagebird.com)
- Basic knowledge of JavaScript, async/await, and Node.js web frameworks
- A phone number in E.164 format for testing (e.g., +14155552671)
Cost Warning: This tutorial sends real SMS messages, which incur charges based on MessageBird's pricing (typically $0.01–$0.10 per message depending on destination country). Monitor your usage in the MessageBird dashboard to avoid unexpected charges.
1. Set Up Your Node.js Fastify Project
Initialize your Node.js project and install the necessary dependencies.
-
Create Your Project Directory:
Open your terminal and create a new directory for your project, then navigate into it.
bashmkdir fastify-messagebird-otp cd fastify-messagebird-otp -
Initialize Your Node.js Project:
Create a
package.jsonfile to manage your project dependencies and scripts.bashnpm init -y -
Install Your Dependencies:
Install Fastify, the MessageBird SDK, templating engine, form body parser, and dotenv.
bashnpm install fastify @fastify/view handlebars @fastify/formbody dotenv messagebirdfastify: The core web framework providing high-performance routing and plugin system.@fastify/view: Plugin for rendering templates.handlebars: The templating engine for HTML views.@fastify/formbody: Plugin to parseapplication/x-www-form-urlencodedrequest bodies.dotenv: Loads environment variables from a.envfile.messagebird: The official Node.js SDK for the MessageBird API.
-
Create Your Project Structure:
Set up a basic directory structure for clarity.
bashmkdir views mkdir views/layouts touch server.js touch .env touch .env.example touch views/step1.hbs touch views/step2.hbs touch views/step3.hbs touch views/layouts/main.hbs touch .gitignoreviews/: Contains your Handlebars template files.views/layouts/: Contains your layout templates (like headers/footers).server.js: Your main application file where you configure and run the Fastify server..env: Stores sensitive information like API keys (never commit this to version control)..env.example: An example file showing required environment variables (safe to commit).step*.hbs: Template files for each step of your OTP flow.main.hbs: Your main layout template..gitignore: Specifies files that Git should ignore.
-
Configure Your Environment Variables:
- Get Your MessageBird API Key:
- Log in to your MessageBird Dashboard.
- Navigate to the "Developers" section in the left-hand menu.
- Click on the "API access (REST)" tab.
- If you don't have a live API key, create one. Important: Use a live key (starts with
live_) for this tutorial, as test keys won't send actual SMS messages. - Copy your live API access key.
- Set up your
.envfiles:- Open
.env.exampleand add this line:plaintext# .env.example MESSAGEBIRD_API_KEY=YOUR_LIVE_MESSAGEBIRD_API_KEY_HERE - Open
.envand add your actual live API key:plaintext# .env MESSAGEBIRD_API_KEY=live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx - Security: Add
.envto your.gitignorefile to prevent accidentally committing your secret key. Open.gitignoreand add:plaintext# .gitignore node_modules .env
- Open
- Get Your MessageBird API Key:
-
Set Up Your Basic Fastify Server:
Open
server.jsand add the initial server configuration:javascript// server.js 'use strict'; // Load environment variables from .env file require('dotenv').config(); // Import dependencies const fastify = require('fastify')({ logger: true }); // Enable logging const path = require('path'); const handlebars = require('handlebars'); // Register Fastify plugins fastify.register(require('@fastify/formbody')); // For parsing form data fastify.register(require('@fastify/view'), { engine: { handlebars: handlebars }, root: path.join(__dirname, 'views'), // Root directory for templates layout: 'layouts/main.hbs' // Specify the main layout file }); // Initialize MessageBird SDK const messagebirdApiKey = process.env.MESSAGEBIRD_API_KEY; if (!messagebirdApiKey) { fastify.log.error('MESSAGEBIRD_API_KEY is not set in environment variables.'); process.exit(1); // Exit if API key is missing } const messagebird = require('messagebird')(messagebirdApiKey); // Note: Modern SDK also supports ES6/TypeScript initialization: // import { initClient } from 'messagebird'; // const messagebird = initClient(messagebirdApiKey); // --- Routes will be added here --- // Start the server const start = async () => { try { await fastify.listen({ port: 3000 }); fastify.log.info(`Server listening on ${fastify.server.address().port}`); } catch (err) { fastify.log.error(err); process.exit(1); } }; start();We initialize
dotenvfirst to load environment variables. We initialize Fastify with logging enabled (logger: true), which uses Pino for high-performance structured logging. We register@fastify/formbodyto handle HTML form submissions and@fastify/viewto render Handlebars templates. We initialize the MessageBird SDK using the API key from the environment variable, with a check to ensure the key exists. A basicstartfunction handles server listening and error logging.
2. Implement Core Functionality (OTP Flow) & 3. Build the API Layer
Build the routes and logic for your OTP flow. Fastify routes handle both core functionality and the API layer.
Step 1: Request Phone Number
-
Create Your Layout (
views/layouts/main.hbs):This file provides the basic HTML structure for all your pages.
html<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Fastify MessageBird OTP 2FA</title> <style> body { font-family: sans-serif; max-width: 600px; margin: 2em auto; padding: 1em; border: 1px solid #ccc; border-radius: 5px; } .error { color: red; border: 1px solid red; padding: 0.5em; margin-bottom: 1em; } label, input { display: block; margin-bottom: 0.5em; } input[type="tel"], input[type="text"] { width: 90%; padding: 0.5em; } input[type="submit"] { padding: 0.7em 1.5em; cursor: pointer; } form { margin-top: 1em; } </style> </head> <body> <h1>Fastify MessageBird OTP 2FA</h1> {{{body}}} <!-- Page content will be injected here --> </body> </html> -
Create Your Phone Number Entry Page (
views/step1.hbs):This template displays the form for entering the phone number.
html{{!-- views/step1.hbs --}} {{#if error}} <div class="error">{{error}}</div> {{/if}} <p>Enter your phone number in international E.164 format (e.g., +14155552671) to receive a verification code:</p> <form method="post" action="/send-otp"> <label for="number">Phone Number:</label> <input type="tel" id="number" name="number" required placeholder="+14155552671" aria-label="Phone number in E.164 format" /> <input type="submit" value="Send Code" /> </form>It includes a conditional block
{{#if error}}to display error messages passed from the server. The formPOSTs data to the/send-otproute. Input typetelhelps mobile browsers display numeric keypads. Thearia-labelattribute improves accessibility for screen readers.E.164 Format Requirements: E.164 is the international phone number format that starts with
+followed by country code (1–3 digits) and subscriber number (up to 15 total digits). Examples: US (+1 415 555 2671), UK (+44 20 7946 0958), Germany (+49 30 12345678). -
Create the Route to Display Your Form (
server.js):Add this route handler before the
start()function call.javascript// server.js - Add inside the main file, before start() // Route to display the initial phone number entry form fastify.get('/', async (request, reply) => { return reply.view('step1.hbs'); // Render the step1 template });
Step 2: Send Verification Code
-
Create Your OTP Entry Page (
views/step2.hbs):This template displays the form for entering the received OTP.
html{{!-- views/step2.hbs --}} {{#if error}} <div class="error">{{error}}</div> {{/if}} <p>We sent a verification code to your phone!</p> <p>Enter the 6-digit code below:</p> <form method="post" action="/check-otp"> <input type="hidden" name="id" value="{{id}}" /> <!-- Hidden field for verification ID --> <label for="token">Verification Code:</label> <input type="text" id="token" name="token" required maxlength="6" pattern="\d{6}" title="Enter the 6-digit code" aria-label="6-digit verification code" /> <input type="submit" value="Verify Code" /> </form> <p><a href="/send-otp">Didn't receive a code? Request a new one</a></p>Includes an error display block and a hidden input field
name="id"that stores theverification IDreceived from MessageBird, which is crucial for the verification step. Security Note: Passing the ID via a hidden field is simple but less secure. For production, store this ID in a server-side session (see Section 7). The formPOSTs data to the/check-otproute. We've added a link to resend the OTP if the user doesn't receive it. -
Create Your Route to Handle Sending OTP (
server.js):Add this route handler.
javascript// server.js - Add inside the main file, before start() // Route to handle form submission and send OTP fastify.post('/send-otp', async (request, reply) => { const { number } = request.body; // Get phone number from form data // Basic validation. For production, use a robust library like google-libphonenumber. if (!number || !/^\+[1-9]\d{1,14}$/.test(number)) { fastify.log.warn(`Invalid phone number format received: ${number}`); return reply.view('step1.hbs', { error: 'Invalid phone number format. Use E.164 format (e.g., +14155552671).' }); } fastify.log.info(`Sending OTP request for number: ${number}`); const params = { originator: 'VerifyApp', // Sender ID shown on the SMS (alphanumeric, max 11 chars, check country restrictions) template: 'Your verification code is %token.', // Message template type: 'sms', // Can be 'sms' or 'tts' (for voice call) // tokenLength: 6, // Default is 6. Valid range: 6-10 characters (MessageBird API specification) // timeout: 30 // Default is 30 seconds. Valid range: 30-172801 seconds (up to 2 days) per MessageBird API specification // maxAttempts: 1 // Default is 1. Valid range: 1-10 attempts before Verify object marked as failed (MessageBird API specification) // datacoding: 'plain' // Default is 'plain'. Options: 'plain' (GSM 03.38), 'unicode' (non-GSM chars), 'auto' (MessageBird API specification) }; // Use a Promise wrapper for the callback-based SDK method // This allows using async/await. Check the latest messagebird SDK docs // for potential native Promise support in newer versions. const createVerify = (phoneNumber, parameters) => { return new Promise((resolve, reject) => { messagebird.verify.create(phoneNumber, parameters, (err, response) => { if (err) { reject(err); } else { resolve(response); } }); }); }; try { const response = await createVerify(number, params); fastify.log.info(`MessageBird Verify Create success for ID: ${response.id}`); // Render the OTP entry form, passing the verification ID // Security Note: Storing response.id in a server-side session is more secure than passing via hidden field. return reply.view('step2.hbs', { id: response.id }); } catch (err) { fastify.log.error('MessageBird Verify Create error:', err); let errorMessage = 'Failed to send verification code. Try again later.'; if (err.errors && err.errors.length > 0) { // Use the first error description from MessageBird if available errorMessage = err.errors[0].description; } // Re-render the initial form with an error message return reply.view('step1.hbs', { error: errorMessage }); } });It retrieves the
numberfrom the parsed form body (request.body) and validates the E.164 format using the regex/^\+[1-9]\d{1,14}$/. Important: For production, use a dedicated library likegoogle-libphonenumberfor reliable international number validation. Parameters forverify.create:originator: The name/number displayed as the sender on the SMS. Note restrictions in some countries (like the US where alphanumeric sender IDs may not work).template: The message text where%tokenis replaced by MessageBird with the generated OTP.type: Set tosmsfor text messages. Usettsfor voice calls, oremailfor email verification.- Optional
timeout: Valid range is 30 to 172,801 seconds (up to 2 days) per MessageBird API specification. Default: 30 seconds. - Optional
tokenLength: Valid range is 6 to 10 characters per MessageBird API specification. Default: 6. - Optional
maxAttempts: Valid range is 1 to 10 attempts per MessageBird API specification. Controls how many failed verification attempts are allowed before the Verify object is marked as failed. Default: 1. - Optional
datacoding: Options are 'plain' (GSM 03.38 characters only), 'unicode' (for non-GSM characters like emoji or non-Latin scripts), or 'auto' (MessageBird auto-detects) per MessageBird API specification. Default: 'plain'.
We wrap the callback-based
messagebird.verify.createin aPromisefor cleanerasync/awaitsyntax. On success, renderstep2.hbs, passing theresponse.id(the verification ID) to the template. On error, log the error and re-renderstep1.hbswith an appropriate error message.Common MessageBird API Error Codes:
- Error code 2: Request not allowed (insufficient balance or account restrictions)
- Error code 9: Missing or invalid parameters
- Error code 20: Phone number is not a valid E.164 number
- Error code 21: Unable to send SMS to this country or network
SMS Delivery Time: Most SMS messages deliver within 2–10 seconds. Network congestion or international routing can cause delays up to 60 seconds. The default 30-second timeout accommodates most scenarios.
Step 3: Verify the Code
-
Create Your Success Page (
views/step3.hbs):Display this template upon successful verification.
html{{!-- views/step3.hbs --}} <div style="color: green; border: 1px solid green; padding: 1em;"> Success! Your phone number has been verified. </div> <p>Next steps: Link your verified phone number to your account for enhanced security.</p> <p><a href="/">Start Over</a></p> -
Create Your Route to Handle OTP Verification (
server.js):Add this final route handler.
javascript// server.js - Add inside the main file, before start() // Route to handle OTP submission and verification fastify.post('/check-otp', async (request, reply) => { const { id, token } = request.body; // Get verification ID and token from form // Basic validation if (!id || !token || !/^\d{6}$/.test(token)) { fastify.log.warn(`Invalid ID or token format received. ID: ${id}, Token: ${token}`); // If the ID is missing (e.g., user tampered with form or state lost), // redirecting to start might be the only option without sessions. if (!id) { fastify.log.warn('Verification ID missing from /check-otp request.'); return reply.redirect('/'); } // If ID exists but token is bad, re-render step2 with error return reply.view('step2.hbs', { id: id, error: 'Invalid code format. Enter the 6-digit code.' }); } fastify.log.info(`Checking OTP for ID: ${id}, Token: ${token}`); // Use a Promise wrapper for the callback-based SDK method const verifyToken = (verificationId, userToken) => { return new Promise((resolve, reject) => { messagebird.verify.verify(verificationId, userToken, (err, response) => { if (err) { reject(err); } else { resolve(response); } }); }); }; try { const response = await verifyToken(id, token); // Verification successful! 'response' contains details if needed. // Possible status values: 'sent', 'expired', 'failed', 'verified', 'deleted' (MessageBird API specification) fastify.log.info(`MessageBird Verify Verify success for ID: ${id}. Status: ${response.status}`); // In a real app, you'd now associate the verified state with the user account. return reply.view('step3.hbs'); // Render success page } catch (err) { fastify.log.error(`MessageBird Verify Verify error for ID: ${id}:`, err); let errorMessage = 'Verification failed. Check the code and try again.'; // Specific error handling (e.g., token incorrect/expired) if (err.errors && err.errors.length > 0) { // Example: Check for common error codes if needed // if (err.errors[0].code === 21) { errorMessage = 'The verification code has expired.'; } // else if (err.errors[0].code === 22) { errorMessage = 'The verification code is invalid.'; } // else { errorMessage = err.errors[0].description; } errorMessage = err.errors[0].description; // Use MessageBird's description } // Re-render the OTP entry form with the ID and an error message // Pass the ID back so the user doesn't lose their verification attempt context. return reply.view('step2.hbs', { id: id, error: errorMessage }); } });Retrieve the
idandtokenfrom the form body and validate the token format using/^\d{6}$/. Handle the missingidcase by redirecting. Wrapmessagebird.verify.verifyin aPromiseand callverifyTokenwith theidandtoken. On success, log the result and renderstep3.hbs. At this point in a real application, mark the user/phone number as verified in your database. On error (e.g., incorrect token, expired token), log the error and re-renderstep2.hbswith theidand an error message.Brute Force Protection: Implement rate limiting (see Section 7) to prevent attackers from guessing OTP codes. MessageBird's
maxAttemptsparameter limits failed verification attempts per verification ID (1–10 attempts, default 1). After reaching the limit, the verification object is marked as failed and cannot be used again.Token Expiration: The default 30-second timeout balances security and usability. Users receive the SMS quickly (2–10 seconds), leaving 20+ seconds to enter the code. For better UX, consider extending to 60–120 seconds if your user base includes less tech-savvy users.
4. Integrate with MessageBird Verify API
This section focuses on MessageBird integration details.
- Configuration: The primary configuration is the
MESSAGEBIRD_API_KEY. - Obtaining API Key:
- Go to the MessageBird Dashboard.
- Click "Developers" in the left navigation menu.
- Select the "API access (REST)" tab.
- View existing keys or click "+ Create new API key".
- Ensure the key type is Live. Give it a descriptive name (e.g., "Fastify OTP App").
- Copy the generated key immediately – it won't be shown again.
- Important: Use a live API key (starts with
live_) for actual SMS delivery. Test keys (start withtest_) will not send real SMS messages and are only for API testing.
- Secure Storage: Store the API key in the
.envfile, which is loaded bydotenv. This file must not be committed to version control (ensure it's in.gitignore).plaintext# .env MESSAGEBIRD_API_KEY=live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx - Initialization: Initialize the SDK once at the start of
server.js:javascript// server.js const messagebird = require('messagebird')(process.env.MESSAGEBIRD_API_KEY); - Fallback Mechanisms: This basic example doesn't implement fallback (e.g., trying voice if SMS fails). For production, consider:
- Catch specific SMS delivery failure errors from MessageBird (if discernible).
- Offer users an alternative method (like voice OTP via
type: 'tts') after a failed SMS attempt. - Monitor MessageBird service status.
- Environment Variables:
MESSAGEBIRD_API_KEY:- Purpose: Authenticates your application with the MessageBird API.
- Format: A string starting with
live_followed by alphanumeric characters. - How to Obtain: Generate in the MessageBird Dashboard under Developers → API access (REST).
API Key Rotation Best Practices:
- Rotate API keys every 90 days or immediately after suspected compromise
- Generate a new key, update your production environment, then delete the old key
- Never reuse API keys across different applications or environments
- Use key names in the dashboard to track which keys are used where
MessageBird API Rate Limits:
- Free tier: 10 API requests per second
- Paid tier: Varies by plan (50–200 requests per second)
- Verify API calls count against your total API limit
- Rate limit headers:
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset
5. Implement Error Handling for MessageBird OTP Verification
- Error Handling Strategy:
- Use
try...catchblocks aroundasyncoperations, especially API calls. - Check for the
errobject in MessageBird SDK callbacks (or the rejected Promise in wrapped functions). - Extract user-friendly error messages from
err.errors[0].descriptionwhen available from MessageBird API errors. - Provide distinct feedback based on error context (e.g., invalid input vs. API failure).
- Render the appropriate view with an
errorvariable passed to the template.
- Use
- Logging:
- Fastify's built-in logger (
fastify.log) is enabled (logger: true). Use levels likeinfo,warn,error. - Log key events: server start, API request initiation, API success/failure, specific errors.
- Example:
fastify.log.error('MessageBird Verify Create error:', err); - For production, configure Fastify's logger (Pino) for structured logging (JSON format), log levels based on environment, and log destinations (file, external service like CloudWatch or Datadog).
- Fastify's built-in logger (
- Retry Mechanisms:
- Client-Side: The current setup relies on users manually retrying by submitting the form again if an error occurs (e.g., "Failed to send code").
- Server-Side (API Calls): Retrying failed MessageBird API calls automatically requires careful consideration.
- Idempotency: MessageBird's
verify.createis generally not idempotent if called with the same number; it will likely initiate a new OTP. Retryingverify.createcould result in multiple SMS messages. - Strategy: Only retry on transient network errors or specific 5xx errors from MessageBird. Implement exponential backoff (e.g., wait 1s, then 2s, then 4s) using libraries like
async-retry. Don't retry validation errors (4xx).
- Idempotency: MessageBird's
Common Error Scenarios and Solutions:
| Error | Cause | Solution |
|---|---|---|
| "Request not allowed" (code 2) | Insufficient balance or account restrictions | Add credit to your MessageBird account or contact support |
| "Invalid phone number" (code 20) | Malformed E.164 format | Validate input with google-libphonenumber library |
| "Unable to send to this country" (code 21) | Country/network not supported | Check MessageBird's coverage in the dashboard |
| "Verification code expired" | User entered code after timeout | Increase timeout parameter or implement resend functionality |
| "Too many attempts" | Rate limit or maxAttempts exceeded | Implement rate limiting and adjust maxAttempts |
Circuit Breaker Pattern for Production:
Implement a circuit breaker to prevent cascading failures when MessageBird API is unavailable. Use libraries like opossum:
// npm install opossum
const CircuitBreaker = require('opossum');
const options = {
timeout: 5000, // If API call takes longer than 5s, trigger failure
errorThresholdPercentage: 50, // Open circuit if 50% of requests fail
resetTimeout: 30000 // Try again after 30s
};
const breaker = new CircuitBreaker(createVerify, options);
breaker.on('open', () => fastify.log.error('MessageBird circuit breaker opened'));
breaker.on('halfOpen', () => fastify.log.info('MessageBird circuit breaker half-open'));
breaker.on('close', () => fastify.log.info('MessageBird circuit breaker closed'));
// Use breaker.fire(number, params) instead of createVerify(number, params)Monitoring and Alerting:
- Set up alerts for error rates exceeding 5% in production
- Monitor average OTP delivery time (should be <10 seconds)
- Track verification success rates (should be >90%)
- Use Fastify plugins like
fastify-metricsfor Prometheus integration
6. Creating a Database Schema and Data Layer
This specific example focuses purely on the OTP flow and doesn't require a database. However, in a real-world application integrating this flow, you would typically need a database to:
- Store User Information: User ID, username, hashed password, phone number, etc.
- Track Verification Status: A boolean flag (
is_phone_verified) on the user record. - Associate Phone Number: Link the verified phone number to the user account.
- Audit Verification Attempts: Log all verification attempts with timestamps, IP addresses, and outcomes for security monitoring.
Conceptual Schema (PostgreSQL):
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Requires pgcrypto extension or use alternatives like uuid-ossp
-- Or use: id BIGSERIAL PRIMARY KEY; for simpler auto-incrementing integers
username VARCHAR(255) UNIQUE NOT NULL,
hashed_password TEXT NOT NULL,
phone_number VARCHAR(20) UNIQUE, -- Store in E.164 format
is_phone_verified BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Index for performance on phone number lookups
CREATE INDEX idx_users_phone_number ON users(phone_number);
CREATE INDEX idx_users_username ON users(username);
-- Optional: Trigger to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_user_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Audit table for verification attempts
CREATE TABLE verification_attempts (
id BIGSERIAL PRIMARY KEY,
user_id UUID REFERENCES users(id),
phone_number VARCHAR(20) NOT NULL,
verification_id VARCHAR(255) NOT NULL, -- MessageBird verification ID
success BOOLEAN NOT NULL,
ip_address INET,
user_agent TEXT,
attempted_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_verification_attempts_user_id ON verification_attempts(user_id);
CREATE INDEX idx_verification_attempts_attempted_at ON verification_attempts(attempted_at);Note: gen_random_uuid() is specific to PostgreSQL and may require enabling the pgcrypto extension. Other databases have different functions for generating UUIDs (e.g., UUID() in MySQL) or you might generate UUIDs in your application code.
Data Layer Implementation (Conceptual using pg library):
// Conceptual data access function - requires `npm install pg`
// const { Pool } = require('pg');
// const pool = new Pool({ /* connection details from environment variables */ });
async function markPhoneAsVerified(userId, phoneNumber) {
// Ensure userId and phoneNumber are properly validated/sanitized before use
const client = await pool.connect();
try {
// Use a transaction for atomicity
await client.query('BEGIN');
const result = await client.query(
'UPDATE users SET phone_number = $1, is_phone_verified = TRUE, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
[phoneNumber, userId]
);
if (result.rowCount === 0) {
throw new Error('User not found');
}
await client.query('COMMIT');
fastify.log.info(`Marked phone as verified for user ${userId}. Rows affected: ${result.rowCount}`);
return result.rowCount > 0; // Return true if update was successful
} catch(dbError) {
await client.query('ROLLBACK');
fastify.log.error(`Database error marking phone verified for user ${userId}:`, dbError);
throw dbError; // Re-throw or handle appropriately
} finally {
client.release();
}
}
async function logVerificationAttempt(userId, phoneNumber, verificationId, success, ipAddress, userAgent) {
const client = await pool.connect();
try {
await client.query(
'INSERT INTO verification_attempts (user_id, phone_number, verification_id, success, ip_address, user_agent) VALUES ($1, $2, $3, $4, $5, $6)',
[userId, phoneNumber, verificationId, success, ipAddress, userAgent]
);
} catch(dbError) {
fastify.log.error('Error logging verification attempt:', dbError);
// Don't throw - logging failures shouldn't break the flow
} finally {
client.release();
}
}
// In the /check-otp success block (inside the try block):
// try {
// const response = await verifyToken(id, token);
// fastify.log.info(`MessageBird Verify Verify success for ID: ${id}. Status: ${response.status}`);
//
// // --- Integration Point ---
// // Assume you get userId and the phone number associated with 'id' from a secure session
// // const { userId, phoneNumber } = request.session.get('verificationContext');
// // if (userId && phoneNumber) {
// // await markPhoneAsVerified(userId, phoneNumber);
// // await logVerificationAttempt(userId, phoneNumber, id, true, request.ip, request.headers['user-agent']);
// // request.session.delete('verificationContext'); // Clear session state
// return reply.view('step3.hbs'); // Render success page
// // } else {
// // fastify.log.error('User context not found in session after successful OTP verification.');
// // // Handle error - maybe redirect to login or show generic error
// // }
// // --- End Integration ---
//
// } catch (err) { ... }
- Migrations: Use tools like
node-pg-migrateor ORM-specific migration tools (e.g., Prisma Migrate, Sequelize CLI) to manage schema changes reliably across environments. - ORM: Consider using an ORM like Prisma or Sequelize for more complex applications to abstract database interactions, provide type safety, and simplify data modeling.
Connection Pooling Configuration:
const { Pool } = require('pg');
const pool = new Pool({
host: process.env.DB_HOST,
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 20, // Maximum number of clients in the pool
idleTimeoutMillis: 30000, // Close idle clients after 30 seconds
connectionTimeoutMillis: 2000, // Return error if connection takes longer than 2 seconds
});
// Handle pool errors
pool.on('error', (err) => {
fastify.log.error('Unexpected database pool error:', err);
});7. Adding Security Features
Beyond the core 2FA logic, several security measures are essential for production.
-
Input Validation:
- Phone Number: Validate format strictly (E.164) on the server-side (
/^\+[1-9]\d{1,14}$/). Use libraries likegoogle-libphonenumberfor robust validation. Sanitize input to prevent potential injection issues. - OTP Token: Validate that it contains only digits and matches the expected length (
/^\d{6}$/). - Use Fastify's Schema Validation: Define JSON schemas for request bodies, params, and querystrings. Fastify automatically validates incoming requests, providing clear errors for invalid input.
javascript
// Example Schema for /send-otp const sendOtpSchema = { body: { type: 'object', required: ['number'], properties: { number: { type: 'string', // Use a pattern that matches E.164 format pattern: '^\\+[1-9]\\d{1,14}$', description: 'Phone number in E.164 format (e.g., +14155552671)' } }, additionalProperties: false // Disallow extra fields } }; // Apply schema to the route // fastify.post('/send-otp', { schema: sendOtpSchema }, async (request, reply) => { /* ... handler logic ... */ });
XSS Protection: Handlebars automatically escapes HTML by default, preventing XSS attacks. Use
{{{ }}}only for trusted content.CSRF Protection: For production, implement CSRF tokens using
@fastify/csrf-protection:javascript// npm install @fastify/csrf-protection // fastify.register(require('@fastify/csrf-protection')); - Phone Number: Validate format strictly (E.164) on the server-side (
-
Rate Limiting:
Crucial to prevent SMS pumping abuse and brute-force attacks.
- Install
@fastify/rate-limit:npm install @fastify/rate-limit - Register and configure the plugin in
server.js:javascript// server.js - Register near other plugins fastify.register(require('@fastify/rate-limit'), { max: 5, // Max requests per time window (adjust based on expected usage) timeWindow: '1 minute', // Time window duration (e.g., '1 minute', 60000 ms) // Key Generator: Limit per phone number for /send-otp, per verification ID for /check-otp, or fallback to IP // Example (needs careful implementation based on where data is available): // keyGenerator: function (req) { // if (req.routeOptions.url === '/send-otp' && req.body?.number) { // return req.body.number; // } // if (req.routeOptions.url === '/check-otp' && req.body?.id) { // return req.body.id; // } // return req.ip; // Fallback to IP address // }, // Add error response customization if needed // errorResponseBuilder: function (req, context) { // return { // code: 'TOO_MANY_REQUESTS', // message: `Rate limit exceeded. Try again after ${context.after}.`, // retryAfter: context.ttl // Time to wait in milliseconds // } // } }); - Apply rate limiting selectively to the sensitive routes (
/send-otp,/check-otp) if needed, or globally as shown above. AdjustmaxandtimeWindowbased on expected usage patterns and risk tolerance.
Choosing Appropriate Rate Limits:
/send-otp: 3–5 attempts per phone number per hour (prevents SMS pumping)/check-otp: 5–10 attempts per verification ID total (MessageBird'smaxAttemptsprovides additional protection)- Global fallback: 100 requests per IP per minute (prevents DDoS)
Distributed Rate Limiting for Multi-Server Deployments: Use Redis as a shared store:
javascript// npm install @fastify/rate-limit ioredis const Redis = require('ioredis'); const redis = new Redis(process.env.REDIS_URL); fastify.register(require('@fastify/rate-limit'), { max: 5, timeWindow: '1 minute', redis: redis, // Use Redis for distributed rate limiting }); - Install
-
Secure Session Management:
- Problem: Passing the
verification IDvia a hidden form field is insecure. - Solution: Use server-side sessions to store the
verification IDand potentially the associated phone number between the/send-otpand/check-otprequests. - Install a session plugin like
@fastify/sessionand a store like@fastify/cookie:npm install @fastify/session @fastify/cookie - Configure sessions in
server.js:javascript// server.js - Register near other plugins // fastify.register(require('@fastify/cookie')); // fastify.register(require('@fastify/session'), { // secret: process.env.SESSION_SECRET, // MUST be a long, random string stored securely (e.g., in .env) // cookie: { secure: process.env.NODE_ENV === 'production', // Use secure cookies in production (HTTPS) // httpOnly: true, // sameSite: 'strict', // Prevent CSRF attacks // maxAge: 15 * 60 * 1000 // Session expiry (e.g., 15 minutes) // }, // // For production, consider using a persistent store like Redis or Postgres instead of the default memory store // // store: require('connect-redis')(session)({ client: redisClient }) // }); // --- Usage Example --- // In /send-otp success: // request.session.verificationContext = { id: response.id, number: number }; // await request.session.save(); // Ensure session is saved // return reply.view('step2.hbs'); // Don't pass ID to view anymore // In /check-otp: // const { id, number } = request.session.verificationContext || {}; // if (!id) { /* Handle missing session state */ } // const { token } = request.body; // // ... proceed with verification using 'id' and 'token' ... // // On success: // // await markPhoneAsVerified(request.session.userId, number); // Assuming userId is also in session // // request.session.destroy(); // Clear session after successful verification - Requires: A strong, randomly generated
SESSION_SECRETenvironment variable (at least 32 characters). Secure cookie settings (secure: truefor HTTPS,httpOnly: true,sameSite: 'strict'). Consider session expiration and persistent stores for production.
Session Fixation Attack Prevention: Regenerate session IDs after successful authentication:
javascript// After successful OTP verification: request.session.regenerate((err) => { if (err) { fastify.log.error('Session regeneration error:', err); } // Continue with authenticated session });Redis Session Store Configuration:
javascript// npm install @fastify/session connect-redis ioredis const Redis = require('ioredis'); const RedisStore = require('connect-redis').default; const redisClient = new Redis(process.env.REDIS_URL); fastify.register(require('@fastify/session'), { store: new RedisStore({ client: redisClient }), secret: process.env.SESSION_SECRET, cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, sameSite: 'strict', maxAge: 15 * 60 * 1000 } }); - Problem: Passing the
-
HTTPS:
Always use HTTPS in production to encrypt communication between the client and server, protecting sensitive data like phone numbers, OTP codes, and session cookies. Configure your deployment environment (e.g., using a reverse proxy like Nginx or Caddy, or platform services like Heroku, AWS ELB) to handle TLS termination.
TLS Configuration Recommendations:
- Use TLS 1.2 or higher (disable TLS 1.0 and 1.1)
- Prefer strong cipher suites:
ECDHE-RSA-AES128-GCM-SHA256,ECDHE-RSA-AES256-GCM-SHA384 - Enable HSTS (HTTP Strict Transport Security) header:
Strict-Transport-Security: max-age=31536000; includeSubDomains - Use certificate pinning for mobile apps
-
Dependency Security:
Regularly audit dependencies for known vulnerabilities using tools like
npm auditor Snyk, and keep them updated.bash# Check for vulnerabilities npm audit # Fix vulnerabilities automatically npm audit fix # Use Snyk for continuous monitoring npm install -g snyk snyk test snyk monitorAutomated Dependency Updates: Use Dependabot (GitHub) or Renovate to automatically create PRs for dependency updates.
-
MessageBird Security:
- Protect your MessageBird API key. Do not expose it client-side.
- Consider IP whitelisting in the MessageBird dashboard if your server has static IPs.
- Monitor MessageBird usage for anomalies.
Webhook Signature Verification: If implementing delivery status callbacks, verify webhook signatures:
javascriptconst crypto = require('crypto'); function verifyMessageBirdWebhook(payload, signature, secret) { const hmac = crypto.createHmac('sha256', secret); hmac.update(payload); const expectedSignature = hmac.digest('hex'); return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expectedSignature) ); } fastify.post('/webhooks/messagebird', async (request, reply) => { const signature = request.headers['messagebird-signature']; const payload = JSON.stringify(request.body); if (!verifyMessageBirdWebhook(payload, signature, process.env.WEBHOOK_SECRET)) { fastify.log.warn('Invalid webhook signature'); return reply.code(401).send({ error: 'Unauthorized' }); } // Process webhook });
Frequently Asked Questions
How do I implement OTP 2FA in Node.js?
Implement OTP 2FA in Node.js by using the MessageBird Verify API with Fastify. Install the messagebird SDK, create routes for sending OTP codes via /send-otp, and verify user-submitted tokens via /check-otp. Use messagebird.verify.create() to generate and send OTP codes, then messagebird.verify.verify() to validate user input.
What is the MessageBird Verify API?
The MessageBird Verify API handles OTP generation, SMS delivery, and token verification for two-factor authentication. It automatically generates random verification codes (6–10 digits), sends them via SMS, email, or voice call, and validates user-submitted tokens within configurable timeout windows (30 seconds to 2 days).
How do I use Fastify for 2FA?
Use Fastify for 2FA by registering the @fastify/formbody plugin to parse form submissions and @fastify/view for template rendering. Create POST routes that integrate with MessageBird's Verify API to send and verify OTP codes. Fastify's high-performance architecture (v5 current) handles authentication flows efficiently with built-in logging via Pino.
What Node.js version does MessageBird SDK require?
MessageBird Node.js SDK requires Node.js >= 0.10 but we recommend Node.js 18.0 (LTS) or higher for production applications. The SDK supports both CommonJS (require('messagebird')) and ES6 module syntax (import { initClient } from 'messagebird'), making it compatible with modern Node.js applications and TypeScript projects.
How long do MessageBird OTP codes remain valid?
MessageBird OTP codes remain valid for 30 seconds by default. You can configure the timeout parameter from 30 to 172,801 seconds (up to 2 days) when calling verify.create(). Set longer timeouts for email-based OTP or shorter windows (30–60 seconds) for SMS to balance security and usability.
Can I customize MessageBird OTP token length?
Yes, set the tokenLength parameter when calling verify.create(). Valid range is 6–10 characters per MessageBird API specification. Shorter codes (6 digits) work better for SMS due to message length constraints, while longer codes (8–10 digits) provide higher entropy for sensitive operations.
How do I secure MessageBird API keys in production?
Store MessageBird API keys in environment variables via .env files (never commit to version control). Use live keys (starting with live_) for production and test keys (test_) for development. Add IP whitelisting in the MessageBird dashboard if your server uses static IPs, and monitor usage for anomalies via the dashboard.
What's the difference between Fastify and Express for 2FA?
Fastify (v5 current) offers 6x faster performance than Express with built-in schema validation via JSON Schema and a robust plugin ecosystem. For 2FA flows, Fastify's native async/await support and low overhead make it ideal for high-throughput authentication services, while Express requires additional middleware for comparable functionality.
What should I do if users don't receive SMS messages?
If users don't receive SMS messages, check these common issues: verify the phone number is in valid E.164 format, ensure your MessageBird account has sufficient balance, confirm the destination country is supported (check MessageBird dashboard), review carrier filtering rules, and implement a fallback to voice OTP (type: 'tts') or email verification. Network delays can take up to 60 seconds in some regions.
How do I handle international SMS delivery?
International SMS delivery requires country-specific considerations: verify MessageBird supports the destination country (200+ countries available), be aware of sender ID restrictions (many countries don't support alphanumeric sender IDs), adjust timeout values for regions with slower carrier networks (60–120 seconds), check local compliance requirements (some countries require pre-registration), and monitor delivery rates per country in your MessageBird dashboard.
Frequently Asked Questions
How to implement 2FA in Node.js with Fastify?
Implement 2FA by using Fastify, the MessageBird Verify API, and the MessageBird Node.js SDK. This combination allows you to create a secure OTP flow, sending codes via SMS and verifying user input for enhanced login security.
What is the MessageBird Verify API used for?
The MessageBird Verify API simplifies OTP generation, delivery via SMS, and code verification. It handles the complexities of sending and validating OTPs, allowing developers to focus on application logic.
Why does 2FA enhance application security?
2FA adds a second authentication factor, typically a user's mobile phone, making it significantly harder for attackers to gain access even with a compromised password. This protects against account takeovers.
When should I use SMS-based OTP 2FA?
Use SMS-based OTP 2FA when you need to strengthen user authentication beyond just username/password logins. This is especially important for sensitive applications or where regulatory compliance mandates stronger security.
Can I customize the SMS message sent with the OTP?
Yes, you can customize the SMS message template using the `template` parameter in the `verify.create` call. The `%token` placeholder will be replaced with the generated OTP. Be mindful of character limits and any country-specific restrictions.
How to send OTP SMS messages with MessageBird?
Use the `messagebird.verify.create` method with the user's phone number and desired parameters like the message template and sender ID. This initiates the OTP generation and SMS delivery process.
What is Fastify used for in this 2FA implementation?
Fastify is a high-performance Node.js web framework used to build the backend service that handles user interaction, API calls to MessageBird, and rendering HTML pages.
How to verify the OTP code entered by the user?
Call the `messagebird.verify.verify` method with the verification ID (received from `verify.create`) and the user-submitted OTP code. This validates the code against MessageBird's records.
What are the prerequisites for setting up this project?
You will need Node.js and npm installed, a MessageBird account with a *live* API key, a mobile phone for testing, and a basic understanding of Node.js, JavaScript, and web concepts.
What technologies are used in this 2FA project?
This project utilizes Node.js with Fastify, the MessageBird Verify API and Node.js SDK, Handlebars for templating, and dotenv for environment variables, providing a comprehensive solution for SMS OTP 2FA.
How to install the required dependencies for the project?
Use `npm install fastify @fastify/view handlebars @fastify/formbody dotenv messagebird` to install all the necessary dependencies for the Fastify server, templating, environment variables and the MessageBird SDK.
Why is proper error handling important for OTP verification?
Proper error handling, including logging and user-friendly feedback, is crucial for a good user experience and to prevent issues like account lockouts if the OTP process encounters problems.
What's a good format for storing international phone numbers?
Store phone numbers in E.164 format (e.g. +14155552671). This format ensures consistency and improves reliability when integrating with services like MessageBird, and is recommended by the article for reliable validation.
What is rate limiting and why use it with OTP?
Rate limiting restricts the number of OTP requests from a user or IP address within a given time window. It's essential to prevent abuse, such as SMS bombing or brute-force attacks, protecting both your MessageBird account and your users.
How to improve the security of the verification ID handling?
Avoid passing the verification ID via a hidden form field. Instead, use server-side sessions to securely store and retrieve the ID between requests, preventing potential tampering.