This guide provides a step-by-step walkthrough for building a secure One-Time Password (OTP) Two-Factor Authentication (2FA) flow in a Node.js application using the Fastify framework and the MessageBird Verify API for SMS delivery. By the end of this tutorial, you will have a functional application that can send OTP codes via SMS and verify user input, adding a crucial layer of security to your user authentication process.
Implementing 2FA significantly enhances application security beyond traditional username/password logins. It protects against account takeovers resulting from compromised passwords by requiring users to possess a secondary factor – typically their mobile phone – to verify their identity. We'll use MessageBird's robust Verify API, which simplifies OTP generation, delivery, and verification. Fastify provides a high-performance, low-overhead web framework for building the backend service efficiently.
Project Overview and Goals
What We'll Build:
A simple Node.js web application using Fastify that demonstrates a complete SMS-based OTP 2FA flow:
- A page prompting the user to enter their phone number.
- Backend logic to trigger an OTP SMS message via the MessageBird Verify API.
- A page prompting the user to enter the received OTP code.
- Backend logic to verify the submitted OTP code using the MessageBird Verify API.
- Success/failure feedback pages.
Problem Solved:
This implementation addresses the need for stronger user authentication by adding a second factor (SMS OTP) to the login or verification process, mitigating risks associated with password-only security.
Technologies Used:
- Node.js: The JavaScript runtime environment.
- Fastify: A high-performance, low-overhead Node.js web framework.
- MessageBird Verify API: A service to handle OTP generation, SMS delivery, and code verification.
messagebird
Node.js SDK: Simplifies interaction with the MessageBird API.@fastify/view
&handlebars
: For server-side template rendering (HTML pages).@fastify/formbody
: To parse URL-encoded form submissions.dotenv
: To manage environment variables securely.
System Architecture:
sequenceDiagram
participant User
participant Browser
participant FastifyApp as Fastify App (Node.js)
participant MessageBird as MessageBird API
User->>Browser: Enters phone number
Browser->>FastifyApp: POST /send-otp (phone number)
FastifyApp->>MessageBird: verify.create(phoneNumber, options)
MessageBird-->>FastifyApp: Success (verification ID) / Error
alt Success
FastifyApp->>MessageBird: Sends SMS with OTP to User's phone
MessageBird-->>User: Receives SMS (OTP Code)
FastifyApp->>Browser: Renders OTP entry page (with verification ID)
User->>Browser: Enters OTP code
Browser->>FastifyApp: POST /check-otp (verification ID, OTP code)
FastifyApp->>MessageBird: verify.verify(verification ID, OTP code)
MessageBird-->>FastifyApp: Success / Error (Invalid Token, Expired, etc.)
alt Success
FastifyApp->>Browser: Renders Success Page
else Error
FastifyApp->>Browser: Renders OTP entry page (with error)
end
else Error (e.g., invalid number)
FastifyApp->>Browser: Renders Phone number entry page (with error)
end
Prerequisites:
- Node.js and npm (or yarn) installed. Download Node.js
- A MessageBird account. Sign up for MessageBird
- A live MessageBird API Key.
- A mobile phone capable of receiving SMS messages for testing.
- Basic understanding of Node.js, JavaScript, and web concepts (HTTP, HTML forms).
Final Outcome:
A functional Node.js application demonstrating SMS OTP 2FA, ready to be integrated into a larger authentication system.
1. Setting up the project
Let's initialize the 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-messagebird-otp cd fastify-messagebird-otp
-
Initialize Node.js Project: Create a
package.json
file to manage project dependencies and scripts.npm init -y
-
Install Dependencies: Install Fastify, the MessageBird SDK, templating engine, form body parser, and dotenv.
npm install fastify @fastify/view handlebars @fastify/formbody dotenv messagebird
fastify
: The core web framework.@fastify/view
: Plugin for rendering templates.handlebars
: The templating engine we'll use.@fastify/formbody
: Plugin to parseapplication/x-www-form-urlencoded
request bodies.dotenv
: Loads environment variables from a.env
file.messagebird
: The official Node.js SDK for the MessageBird API.
-
Create Project Structure: Set up a basic directory structure for clarity.
mkdir 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 .gitignore
views/
: Contains Handlebars template files.views/layouts/
: Contains layout templates (like headers/footers).server.js
: The main application file where the Fastify server is configured and run..env
: Stores sensitive information like API keys (this file should not be committed to version control)..env.example
: An example file showing required environment variables (safe to commit).step*.hbs
: Template files for each step of the OTP flow.main.hbs
: The main layout template..gitignore
: Specifies intentionally untracked files that Git should ignore.
-
Configure Environment Variables:
- Get 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 for this tutorial, as test keys may not send actual SMS messages.
- Copy your live API access key.
- Set up
.env
files:- Open
.env.example
and add the following line:# .env.example MESSAGEBIRD_API_KEY=YOUR_LIVE_MESSAGEBIRD_API_KEY_HERE
- Open
.env
and add your actual live API key:# .env MESSAGEBIRD_API_KEY=live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
- Security: Add
.env
to your.gitignore
file to prevent accidentally committing your secret key. Open.gitignore
and add:# .gitignore node_modules .env
- Open
- Get MessageBird API Key:
-
Basic Fastify Server Setup: Open
server.js
and add the initial server configuration:// 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); // --- 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
dotenv
first to load environment variables. - We initialize Fastify with logging enabled (
logger: true
). - We register
@fastify/formbody
to handle HTML form submissions. - We register
@fastify/view
and configure it to use Handlebars, pointing to ourviews
directory and specifying the default layout. - We initialize the MessageBird SDK using the API key from the environment variable. A check ensures the key is present.
- A basic
start
function handles server listening and error logging.
- We initialize
2. Implementing Core Functionality (OTP Flow) & 3. Building the API Layer
Now, let's build the routes and logic for the OTP flow. We'll combine core functionality and the API layer as Fastify routes handle both.
Step 1: Request Phone Number
-
Create Layout (
views/layouts/main.hbs
): This file provides the basic HTML structure for all pages.<!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 Phone Number Entry Page (
views/step1.hbs
): This template displays the form for entering the phone number.{{!-- views/step1.hbs --}} {{#if error}} <div class=""error"">{{error}}</div> {{/if}} <p>Please 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"" /> <input type=""submit"" value=""Send Code"" /> </form>
- It includes a conditional block
{{#if error}}
to display error messages passed from the server. - The form
POST
s data to the/send-otp
route. - Input type
tel
helps mobile browsers display numeric keypads.
- It includes a conditional block
-
Create Route to Display the Form (
server.js
): Add this route handler before thestart()
function call.// 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 OTP Entry Page (
views/step2.hbs
): This template displays the form for entering the received OTP.{{!-- views/step2.hbs --}} {{#if error}} <div class=""error"">{{error}}</div> {{/if}} <p>We have sent a verification code to your phone!</p> <p>Please 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"" /> <input type=""submit"" value=""Verify Code"" /> </form>
- Includes an error display block.
- A hidden input field
name=""id""
stores theverification ID
received from MessageBird, which is crucial for the verification step. Security Note: Passing the ID via a hidden field is simple but less secure. A user could potentially tamper with it. For production, storing this ID in a server-side session (see Section 7) is strongly recommended. - The form
POST
s data to the/check-otp
route.
-
Create Route to Handle Sending OTP (
server.js
): Add this route handler.// 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. Please 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 // timeout: 60 // Default is 30 seconds }; // 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. Please 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
number
from the parsed form body (request.body
). - Includes basic E.164 format validation using the regex
/^\+[1-9]\d{1,14}$/
. Important: For production, use a dedicated library likegoogle-libphonenumber
for more reliable international number validation. - Sets parameters for the
verify.create
call:originator
: The name/number displayed as the sender on the SMS. Note restrictions in some countries (like the US).template
: The message text.%token
is replaced by MessageBird with the generated OTP.type
: Set tosms
. Usetts
for voice calls.- Optional
timeout
andtokenLength
can be added.
- We wrap the callback-based
messagebird.verify.create
in aPromise
for cleanerasync/await
syntax. A comment notes why this is done and suggests checking for native Promise support in the SDK. - On success, it renders
step2.hbs
, passing theresponse.id
(the verification ID) to the template. A security note reinforces the recommendation for using sessions instead of a hidden field. - On error, it logs the error and re-renders
step1.hbs
with an appropriate error message, potentially using the description from the MessageBird error response.
- It retrieves the
Step 3: Verify the Code
-
Create Success Page (
views/step3.hbs
): This template is shown upon successful verification.{{!-- views/step3.hbs --}} <div style=""color: green; border: 1px solid green; padding: 1em;""> Success! Your phone number has been verified. </div> <p><a href=""/"">Start Over</a></p>
-
Create Route to Handle OTP Verification (
server.js
): Add this final route handler.// 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. Please 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. 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. Please 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 }); } });
- Retrieves the
id
andtoken
from the form body. - Includes basic validation for the token format using the regex
/^\d{6}$/
. Handles the missingid
case by redirecting, noting this limitation without sessions. - Wraps
messagebird.verify.verify
in aPromise
. - Calls
verifyToken
with theid
andtoken
. - On success, it logs the success and renders the
step3.hbs
success page. At this point in a real application, you would typically mark the user/phone number as verified in your database. - On error (e.g., incorrect token, expired token), it logs the error and re-renders
step2.hbs
, passing back theid
and an appropriate error message derived from the MessageBird response.
- Retrieves the
4. Integrating with necessary third-party services
This section focuses specifically on the MessageBird integration details covered during setup.
- 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.
- Secure Storage: The API key is stored in the
.env
file, which is loaded bydotenv
. This file must not be committed to version control (ensure it's in.gitignore
).# .env MESSAGEBIRD_API_KEY=live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
- Initialization: The SDK is initialized once at the start of
server.js
:// 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:
- Catching specific SMS delivery failure errors from MessageBird (if discernible).
- Offering the user an alternative method (like voice OTP via
type: 'tts'
) after a failed SMS attempt. - Monitoring 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).
5. Implementing proper error handling, logging, and retry mechanisms
- Error Handling Strategy:
- Use
try...catch
blocks aroundasync
operations, especially API calls. - Check for the
err
object in MessageBird SDK callbacks (or the rejected Promise in our wrapped functions). - Extract user-friendly error messages from
err.errors[0].description
when available from MessageBird API errors. - Provide distinct feedback to the user based on the error context (e.g., invalid input vs. API failure).
- Render the appropriate view with an
error
variable 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, incoming requests (handled by Fastify), API request initiation, API success/failure, specific errors encountered.
- 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).
- Fastify's built-in logger (
- Retry Mechanisms:
- Client-Side: The current setup relies on the user 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.create
is generally not idempotent if called with the same number; it will likely initiate a new OTP. Retryingverify.create
could 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
. Avoid retrying validation errors (4xx). - Example (Conceptual): To implement this, you would first need to install the library:
npm install async-retry
.// const retry = require('async-retry'); // try { // const response = await retry(async bail => { // // Use the Promise wrapper from before // try { // return await createVerify(number, params); // } catch (err) { // // Don't retry on MessageBird's client-side validation errors (4xx) // if (err.statusCode >= 400 && err.statusCode < 500) { // bail(new Error(`Not retrying on client error: ${err.message}`)); // bail stops retries // } // throw err; // Throw other errors to trigger retry // } // }, { // retries: 3, // Number of retries // factor: 2, // Exponential backoff factor // minTimeout: 1000 // Initial timeout // }); // // Process successful response... // } catch (err) { // // Handle final error after retries... // }
- Retrying
verify.verify
might be safer but could lock out users if retried too aggressively with wrong codes. Rate limiting (Section 7) is generally a better approach here.
- Idempotency: MessageBird's
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.
Conceptual Schema (e.g., 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
);
-- 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();
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 {
const result = await client.query(
'UPDATE users SET phone_number = $1, is_phone_verified = TRUE, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
[phoneNumber, userId]
);
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) {
fastify.log.error(`Database error marking phone verified for user ${userId}:`, dbError);
throw dbError; // Re-throw or handle appropriately
} 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);
// // 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-migrate
or 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.
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-libphonenumber
for robust validation. Sanitize input to prevent potential injection issues (less common with phone numbers but good practice). - 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.
// 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 ... */ });
- 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
:// 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. Please 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. Adjustmax
andtimeWindow
based on expected usage patterns and risk tolerance. Implementing akeyGenerator
based on phone number or verification ID (requires session state or careful handling) is more effective than IP-based limiting alone.
- Install
-
Secure Session Management:
- Problem: Passing the
verification ID
via a hidden form field is insecure. - Solution: Use server-side sessions to store the
verification ID
and potentially the associated phone number between the/send-otp
and/check-otp
requests. - Install a session plugin like
@fastify/session
and a store like@fastify/cookie
:npm install @fastify/session @fastify/cookie
- Configure sessions in
server.js
:// 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, // 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_SECRET
environment variable. Secure cookie settings (secure: true
for HTTPS,httpOnly: true
). Consider session expiration and persistent stores for production.
- 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.
-
Dependency Security: Regularly audit dependencies for known vulnerabilities using tools like
npm audit
or Snyk, and keep them updated. -
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.