How to Build SMS OTP Verification with MessageBird & Node.js (2025 Guide)
Learn how to implement secure SMS-based two-factor authentication using MessageBird Verify API and Node.js. Complete tutorial with code examples, security best practices, and production-ready error handling.
Two-factor authentication (2FA) adds a critical layer of security to user accounts by requiring a password plus a verification code sent to a trusted device. One-time passwords (OTPs) delivered via SMS remain one of the most accessible methods for implementing 2FA in web applications.
⚠️ Security Note: While SMS-based OTP is functional and widely deployed, OWASP security guidelines (2025) identify SMS OTP as less secure than alternative 2FA methods due to vulnerabilities including SIM swapping attacks, SS7 protocol exploits, phishing, and man-in-the-middle attacks. Leading organizations (Microsoft, Twitter) have migrated to more secure alternatives like TOTP authenticator apps, push notifications, or hardware tokens. SMS OTP remains acceptable for low-to-medium security applications but should be combined with additional security measures (rate limiting, device fingerprinting, anomaly detection) for higher-risk use cases. Source:OWASP Multifactor Authentication Cheat Sheet (accessed January 2025)
This guide walks you through building a functional OTP verification flow in a Node.js application using the Express framework and the MessageBird Verify API. You'll build a simple web application that requests a user's phone number, sends a verification code via MessageBird, and validates the code entered by the user.
Project Goals:
Build a Node.js Express application demonstrating SMS-based OTP verification.
Securely integrate with the MessageBird Verify API.
Implement a clear user flow for entering a phone number and verifying the OTP.
Provide robust error handling and outline basic security considerations.
Technologies Used:
Node.js: JavaScript runtime environment.
Express: Minimalist web framework for Node.js.
MessageBird Verify API: Service for sending and verifying OTPs via SMS or voice.
MessageBird Node.js SDK: Simplifies interaction with the MessageBird API.
Handlebars: Templating engine for rendering HTML views.
dotenv: Module to load environment variables from a .env file.
body-parser: Middleware to parse incoming request bodies. Note: As of Express 4.16.0+, body-parser functionality is built into Express via express.urlencoded() and express.json(). The standalone body-parser package is no longer required for basic use cases, though it remains available for advanced configurations. This tutorial uses body-parser for clarity but you can replace it with Express built-in methods. Source:Express body-parser middleware documentation (accessed January 2025)
System Architecture:
The flow involves these components:
User's Browser: Interacts with the Express application, submitting the phone number and OTP.
Node.js/Express Server:
Serves HTML pages (via Handlebars).
Receives user input (phone number, OTP).
Communicates with the MessageBird API via the SDK.
Handles the logic for initiating and verifying OTPs.
MessageBird Verify API:
Generates a secure OTP.
Sends the OTP via SMS to the user's phone number.
Receives verification requests from the Express server and confirms if the provided OTP is correct for the specific verification attempt.
Prerequisites:
Node.js 14.x or higher and npm (or yarn) installed.
A MessageBird account with a Live API Key.
A text editor or IDE (like VS Code).
Basic understanding of Node.js, Express, and asynchronous JavaScript.
1. Setting Up Your Node.js Project
Create your project directory and initialize it.
Create Project Directory:
Open your terminal and create a new directory for the project, then navigate into it.
Bash
mkdir node-messagebird-otp
cd node-messagebird-otp
Initialize npm:
Initialize the project with npm. This creates a package.json file.
Bash
npm init -y
The -y flag accepts the default settings.
Install Dependencies:
Install Express for the web server, Handlebars for templating, the MessageBird SDK, dotenv for environment variables, and body-parser to handle form submissions.
Navigate to the Developers section in the left-hand sidebar.
Click on the API access (REST) tab.
If you don't have a key, click "Add access key." Create a Live key, not a test key. The Verify API generally requires a Live key for full functionality, unlike some other APIs that might work partially with Test keys.
Copy your Live API access key. Treat this key like a password – keep it secret!
Create .env File:
In the root of your project directory (node-messagebird-otp/), create a file named .env.
Add API Key to .env:
Add your copied Live API key to the .env file:
.env
# .env
MESSAGEBIRD_API_KEY=YOUR_LIVE_API_KEY_HERE
Important: Replace YOUR_LIVE_API_KEY_HERE with the actual key you copied from the MessageBird dashboard.
Purpose: The MESSAGEBIRD_API_KEY variable holds your secret key. dotenv loads this into process.env so your application can access it without hardcoding it in the source code.
3. Building the Express Application
Set up the basic Express server and configure Handlebars.
Create index.js:
Create the main application file, index.js, in the project root.
Initial Setup (index.js):
Add the following code to index.js to require dependencies and initialize the Express app, MessageBird SDK, and Handlebars:
JavaScript
// index.js// 1. Require Dependenciesconst express = require('express');
const exphbs = require('express-handlebars');
const bodyParser = require('body-parser');
const dotenv = require('dotenv');
// 2. Load Environment Variables
dotenv.config(); // This loads the variables from .env into process.env// 3. Initialize MessageBird SDK// Ensure MESSAGEBIRD_API_KEY is set in your .env fileif (!process.env.MESSAGEBIRD_API_KEY || process.env.MESSAGEBIRD_API_KEY === 'YOUR_LIVE_API_KEY_HERE') {
console.error('Error: MESSAGEBIRD_API_KEY is not set or is still the placeholder in the .env file.');
process.exit(1); // Exit if the API key is missing or not replaced
}
const messagebird = require('messagebird')(process.env.MESSAGEBIRD_API_KEY);
// 4. Initialize Express Appconst app = express();
const port = process.env.PORT || 8080; // Use port from env or default to 8080// 5. Configure Handlebars// Note: express-handlebars v6.0+ requires using .engine property// Older versions (v5.x) used: exphbs({ defaultLayout: 'main' })// Current syntax for v6.0+: exphbs.engine({ defaultLayout: 'main' })// Source: express-handlebars npm changelog (January 2025)
app.engine('handlebars', exphbs.engine({
defaultLayout: 'main'
}));
app.set('view engine', 'handlebars');
// Specify the views directory (optional if it's named 'views')
app.set('views', './views');
// 6. Configure Body Parser Middleware// This is needed to parse data from HTML forms// Alternative for Express 4.16.0+: app.use(express.urlencoded({ extended: true }));
app.use(bodyParser.urlencoded({ extended: true }));
// 7. Define Routes (We will add these in the next section)// app.get('/', ...);// app.post('/step2', ...);// app.post('/step3', ...);// 8. Start the Server
app.listen(port, () => {
console.log(`Server listening on http://localhost:${port}`);
});
Require necessary modules.
dotenv.config() loads the .env file.
Initialize the MessageBird SDK, passing the API key from process.env. A check ensures the key exists and isn't the placeholder value.
Set up Express and define the port (allowing override via environment variable).
Configure Handlebars as the view engine, specifying the default layout file.
Add body-parser middleware to access form data via req.body.
Create Main Layout (views/layouts/main.handlebars):
This file defines the basic HTML structure for all pages. Create views/layouts/main.handlebars with the following content:
The {{{body}}} placeholder is where content from other Handlebars views (step1, step2, step3) will be rendered.
4. Implementing SMS OTP Verification Flow
Implement the core logic: requesting the phone number, sending the OTP, and verifying the OTP.
Step 1: Requesting the Phone Number
Create View (views/step1.handlebars):
This view presents a form for the user to enter their phone number. Create views/step1.handlebars:
handlebars
{{! views/step1.handlebars }}
{{#if error}}
<p class="error">{{error}}</p>
{{/if}}
<p>Enter your phone number in international format (e.g., +14155552671) to receive a verification code:</p>
<form method="post" action="/step2">
<label for="number">Phone Number:</label>
<input type="tel" id="number" name="number" placeholder="+1234567890" required aria-label="Phone number in international format" />
<input type="submit" value="Send Code" />
</form>
{{#if error}}...{{/if}}: Conditionally displays an error message if passed from the server.
The form POSTs data to the /step2 route.
The input name="number" makes the value accessible as req.body.number on the server.
type="tel" hints to browsers (especially mobile) that this is a phone number field.
Step 2: Sending the Verification Code
When the user submits their phone number, call the MessageBird Verify API to send the OTP.
Create View (views/step2.handlebars):
This view asks the user to enter the code they received. It also includes a hidden field to pass the verification id to the next step. Create views/step2.handlebars:
handlebars
{{! views/step2.handlebars }}
{{#if error}}
<p class="error">{{error}}</p>
{{/if}}
<p>We sent a verification code to your phone!</p>
<p>Enter the 6-digit code below. The code expires in 30 seconds.</p>
<form method="post" action="/step3">
{{! Hidden input to carry the verification ID }}
<input type="hidden" name="id" value="{{id}}" />
<label for="token">Verification Code:</label>
<input type="text" id="token" name="token" pattern="\d{6}" title="Enter the 6-digit code" required aria-label="6-digit verification code" />
<input type="submit" value="Verify Code" />
</form>
<p><a href="/">Request a new code</a></p>
name="id" and value="{{id}}": Passes the verification ID received from MessageBird.
name="token": The input for the user-entered OTP.
pattern="\d{6}": Basic HTML5 validation for a 6-digit code.
Added code expiry information and a link to request a new code.
Create Route (index.js):
Add the POST /step2 route in index.js to handle the form submission from step1:
JavaScript
// index.js - Add this route before app.listen()// Route to handle phone number submission and initiate verification
app.post('/step2', (req, res) => {
const number = req.body.number;
// Basic validation (insufficient for production; consider libraries like 'google-libphonenumber' for robust E.164 validation - see Security section)if (!number || !/^\+[1-9]\d{1,14}$/.test(number)) {
console.warn(`Invalid phone number format attempt: ${number}`);
// Note: This regex is a basic check. Production apps should implement more robust E.164 validation// using libraries like google-libphonenumber (discussed further in the Security section)// to handle various international formats correctly.return res.render('step1', { error: 'Invalid phone number format. Use international format (e.g., +14155552671).' });
}
console.log(`Initiating verification for: ${number}`);
// Call MessageBird Verify API
messagebird.verify.create(number, {
// originator: 'VerifyApp', // Sender ID (alpha/numeric, max 11 chars or phone number)// IMPORTANT: Originator ('From' field) requirements vary drastically by country.// Alphanumeric senders (like 'VerifyApp') may require pre-registration or may not work// in regions like the US/Canada. Using a purchased MessageBird virtual number is often more reliable.// Check MessageBird documentation for country-specific Sender ID rules before deploying.originator: process.env.MESSAGEBIRD_ORIGINATOR || 'MessageBird', // Use env var or defaulttemplate: 'Your verification code is %token.', // %token is replaced by the actual OTP// type: 'sms', // Default is sms// tokenLength: 6, // Default is 6// timeout: 30 // Default is 30 seconds
}, (err, response) => {
if (err) {
// Handle API errorsconsole.error('MessageBird Verify API error:', err);
let errorMessage = 'Failed to send verification code. Try again.';
// Provide more specific feedback if possible, checking safelyif (err.errors && err.errors.length > 0 && err.errors[0].description) {
errorMessage = err.errors[0].description;
} elseif (err.statusCode === 401) {
errorMessage = 'Authentication failed. Check your API key.'
}
return res.render('step1', {
error: errorMessage
});
}
// Verification initiated successfullyconsole.log('Verification response:', response); // Log the full response for debugging// Render step 2, passing the verification ID
res.render('step2', {
id: response.id// Pass the verification ID to the next view
});
});
});
Extract the number from req.body.
Basic validation checks if the number looks like an international format. Production apps need more robust validation. A warning comment and prose highlight this limitation.
messagebird.verify.create(number, options, callback) is called.
number: The user's phone number.
options:
originator: The sender ID displayed on the user's phone. Regulations vary. Alphanumeric IDs might require registration or fail in certain countries (e.g., US/Canada). Using a purchased MessageBird number is often safer. Consult MessageBird's country-specific rules. An environment variable MESSAGEBIRD_ORIGINATOR is suggested for flexibility.
template: The SMS message body. %token is mandatory and will be replaced by the generated OTP.
timeout (optional): How long the code is valid (default 30 seconds).
callback(err, response): Handles the asynchronous response.
If err, log the error and re-render step1 with an error message extracted safely from err.errors[0].description or a generic message.
If successful (response), log the response and render step2, passing response.id to the view. This id uniquely identifies this verification attempt.
Step 3: Verifying the Code
Take the id and the user-entered token and ask MessageBird if they match.
Create View (views/step3.handlebars):
A simple success message. Create views/step3.handlebars:
handlebars
{{! views/step3.handlebars }}
<h2>Verification Successful!</h2>
<p>Your phone number has been successfully verified.</p>
<p><a href="/">Start Over</a></p>
Create Route (index.js):
Add the POST /step3 route in index.js to handle the submission from step2:
JavaScript
// index.js - Add this route before app.listen()// Route to handle OTP submission and complete verification
app.post('/step3', (req, res) => {
const id = req.body.id; // Verification ID from hidden inputconst token = req.body.token; // OTP entered by the userif (!id || !token) {
console.warn('Missing ID or Token in verification attempt.');
// Avoid revealing which one is missing to the userreturn res.render('step2', {
error: 'Verification failed. Try again.',
id: id // Pass ID back if available
});
}
console.log(`Verifying code for ID: ${id}, Token: ${token}`);
// Call MessageBird Verify API to verify the token
messagebird.verify.verify(id, token, (err, response) => {
if (err) {
// Verification failed (invalid token, expired, etc.)console.error('MessageBird Verify token error:', err);
let errorMessage = 'Verification failed. Check the code and try again.';
// Provide specific API error if available, checking safelyif (err.errors && err.errors.length > 0 && err.errors[0].description) {
errorMessage = err.errors[0].description + ' Try sending a new code.';
}
// Re-render step 2 with error, passing the original ID backreturn res.render('step2', {
error: errorMessage,
id: id // Pass ID back so the form still works for retry (if applicable)
});
}
// Verification successful!console.log('Verification successful:', response);
// IMPORTANT: In a real app, you would now update the user's session/state// to indicate successful verification before rendering success.// This example just shows the success page directly.
res.render('step3');
});
});
Get the id and token from req.body. Basic check added for their presence.
messagebird.verify.verify(id, token, callback) is called.
id: The unique ID from the verify.create response.
token: The OTP code entered by the user.
callback(err, response):
If err, verification failed (wrong code, expired, etc.). Log the error and re-render step2 with an error message (extracted safely) and the original id (so the hidden field remains populated).
If successful (response), the code was correct. Log success and render the step3 success view. A comment highlights that real apps need session updates here.
5. Testing Your OTP Application
Your basic OTP verification flow is complete!
Save Files: Ensure all files (index.js, .env, .gitignore, and all .handlebars files in the views directories) are saved. Make sure you replaced the placeholder in .env.
Start the Server: Open your terminal in the node-messagebird-otp directory and run:
Bash
node index.js
You should see: Server listening on http://localhost:8080. If you see an error about the API key, double-check your .env file.
Test: Open your web browser and navigate to http://localhost:8080.
Enter your phone number in international format (e.g., +14155551234).
Click "Send Code."
Check your phone for an SMS containing the code (check spam if needed, and ensure your originator is valid).
Enter the code on the verification page.
Click "Verify Code."
You should see the "Verification Successful!" page. Try entering an incorrect code or waiting too long (over 30 seconds) to see the error handling.
Common Issues:
Port already in use: If port 8080 is busy, set PORT=3000 in your .env file and restart.
Module not found: Run npm install again to ensure all dependencies are installed.
API key errors: Verify your .env file contains the correct Live API key.
6. Error Handling and Logging Best Practices
Production applications require robust error handling strategies beyond basic console.log:
Logging: Use a dedicated logging library like Winston or Pino. Configure log levels (debug, info, warn, error) and output formats (JSON works well with log aggregation tools). Log critical events: verification initiation, API errors, successful verification, failed verification attempts.
MessageBird Errors: The err object from the MessageBird SDK often contains an errors array (e.g., err.errors[0]). Use err.errors[0].code for specific error types and err.errors[0].description (accessed safely, as shown) for user-friendly messages. Refer to MessageBird API documentation for error code meanings.
User Feedback: Provide clear, non-technical error messages to the user. Avoid exposing raw API errors directly. Guide the user on what to do next (e.g., "Invalid code, try again," "Code expired, request a new one").
Retry Mechanisms: For transient network errors when calling MessageBird, implement simple retry logic (e.g., retry once or twice with a short delay). For user input errors (invalid token), let the user resubmit the form on the /step2 page. If the code expires or too many attempts fail, guide the user back to / to request a new code.
Example Logging Enhancement (Conceptual):
Note: The following snippet is conceptual. Implementing this requires choosing and configuring a specific logging library (e.g., Winston, Pino) and integrating it into your application.
JavaScript
// Conceptual example using a placeholder logger// const logger = require('./logger'); // Assume a configured logger exists// Inside verify.create callback error handling:// logger.error({// message: 'MessageBird Verify API error during creation',// apiError: err, // Log the full error object for detailed debugging// userNumber: number // Log relevant context (avoid logging sensitive data excessively)// });// return res.render('step1', { error: userFriendlyMessage });// Inside verify.verify callback error handling:// logger.warn({// message: 'MessageBird token verification failed',// verifyId: id,// apiError: err// });// return res.render('step2', { error: userFriendlyMessage, id: id });
7. Security Considerations for SMS Authentication
While MessageBird handles OTP generation and security, consider these points in your application:
Input Validation:
Phone Numbers: The basic regex ^\+[1-9]\d{1,14}$ is a start but insufficient for production. Use libraries like google-libphonenumber (npm install google-libphonenumber) for robust parsing and validation according to E.164 standards. Sanitize input to prevent potential injection issues. Learn more about phone number validation best practices.
OTP Token: Ensure the token format matches expectations (e.g., 6 digits). body-parser helps prevent basic payload manipulation, but always validate input types and lengths on the server.
Rate Limiting: Crucial to prevent abuse (repeatedly sending OTPs to a number, brute-forcing tokens). Implement rate limiting on:
The /step2 endpoint (requesting OTPs): Limit requests per phone number and/or IP address per time window (e.g., 1 request per minute, 5 requests per hour per number).
The /step3 endpoint (verifying OTPs): Limit verification attempts per verification ID and/or IP address per time window (e.g., 5 attempts per 15 minutes per ID).
Use middleware like express-rate-limit.
API Key Security: Never commit your .env file or hardcode the API key. Use environment variables managed securely in your deployment environment.
HTTPS: Always use HTTPS in production to encrypt communication between the user's browser and your server.
Session Management (Critical Gap in this Example): This guide demonstrates the OTP sending and verification mechanism in isolation. A real-world login or 2FA flow requires proper session management (e.g., using express-session with a secure store). After successful OTP verification (Step 3), update the user's server-side session state to grant access or mark the 2FA challenge as complete, rather than just showing a success page. The current example does not implement this critical session component.
Secure State Management: Passing the verification id via a hidden form field between steps is simple but relies on client-side state. For higher security, especially in complex flows, consider storing the verification attempt status (e.g., "pending," "verified") server-side, perhaps linked to the user's session or a short-lived database record, instead of solely relying on the client passing the id back correctly.
Brute Force Protection: Rate limiting on /step3 helps. You could also implement lockouts after too many failed attempts for a specific verification ID or user account (if integrated with user accounts).
CSRF Protection: Implement CSRF tokens using libraries like csurf to protect your forms from cross-site request forgery attacks.
Example Rate Limiting (Conceptual):
Note: The following snippet using express-rate-limit is conceptual. Install the library (npm install express-rate-limit) and integrate this middleware into your routes.
JavaScript
// Conceptual example using express-rate-limit// const rateLimit = require('express-rate-limit');// // Limit OTP requests per phone number// const otpRequestLimiter = rateLimit({// windowMs: 15 * 60 * 1000, // 15 minutes// max: 5, // Limit each number to 5 requests per windowMs// message: 'Too many OTP requests from this number, try again after 15 minutes.',// keyGenerator: (req, res) => req.body.number, // Use phone number as key// handler: (req, res, next, options) => {// console.warn(`Rate limit exceeded for OTP request: ${req.body.number}`);// res.status(options.statusCode).render('step1', { error: options.message });// }// });// // Limit verification attempts per verification ID// const otpVerifyLimiter = rateLimit({// windowMs: 10 * 60 * 1000, // 10 minutes// max: 10, // Limit each ID to 10 verification attempts per windowMs// message: 'Too many verification attempts, try again later or request a new code.',// keyGenerator: (req, res) => req.body.id, // Use verification ID as key// handler: (req, res, next, options) => {// console.warn(`Rate limit exceeded for OTP verification: ${req.body.id}`);// // Pass ID back so form still works potentially// res.status(options.statusCode).render('step2', { error: options.message, id: req.body.id });// }// });// app.post('/step2', otpRequestLimiter, (req, res) => { /* ... route logic ... */ });// app.post('/step3', otpVerifyLimiter, (req, res) => { /* ... route logic ... */ });
8. Testing Strategies for 2FA Implementation
Manual Testing: Follow the steps in Section 5. Test edge cases:
Invalid phone number formats (non-international, too short/long).
Incorrect OTP codes.
Expired OTP codes (wait > 30 seconds before submitting).
Submitting the forms with empty fields.
Rapidly requesting codes or attempting verification (if rate limiting is implemented).
Automated Testing (Recommended for Production):
Unit Tests: Test individual functions or modules in isolation (e.g., phone number validation logic). Mock the MessageBird SDK calls using libraries like sinon or jest.mock.
Integration Tests: Test the interaction between different parts of your application, including routes. Use libraries like supertest to make HTTP requests to your running Express app and assert responses. You would still likely mock the actual MessageBird API calls to avoid sending real SMS messages and incurring costs during tests.
9. Deployment Considerations
Environment Variables: Ensure MESSAGEBIRD_API_KEY (and potentially MESSAGEBIRD_ORIGINATOR) is set as an environment variable in your deployment platform (Heroku Config Vars, AWS Secrets Manager, Docker environment variables, etc.). Do not deploy your .env file.
Platform: Choose a hosting platform (Heroku, AWS EC2/ECS/Lambda, Google Cloud Run, DigitalOcean App Platform, etc.). Follow their specific Node.js deployment guides.
package.json Scripts: Add a start script to your package.json:
JSON
// package.json"scripts":{"start":"node index.js","test":"echo \"Error: no test specified\" && exit 1"},
Most platforms use npm start to run your application.
HTTPS: Configure HTTPS on your deployment platform or via a load balancer/proxy.
CI/CD (Continuous Integration/Continuous Deployment): Set up a pipeline (GitHub Actions, GitLab CI, Jenkins) to automatically test and deploy your application when changes are pushed to your repository.
10. Troubleshooting Common Issues
Invalid API Key: Error messages like "Authentication failed" usually point to an incorrect or missing MESSAGEBIRD_API_KEY. Double-check the key in your environment variables/.env file and ensure it's a Live key and not the placeholder.
Invalid Phone Number: Errors like "recipient is invalid" mean the number format submitted to the API was incorrect. Ensure it's in international format (e.g., +1...). Use robust validation (Section 7).
SMS Not Received:
Check the MessageBird Dashboard logs (often under "Verify API Logs," "SMS Logs," or similar) for detailed delivery statuses and error messages directly from carriers. This is the most common way to diagnose delivery issues.
Verify the originator being used is valid for the destination country. Alphanumeric senders are restricted in some regions (like the US/Canada) and may require pre-registration. Try using a purchased MessageBird virtual number as the originator if allowed. Check MessageBird's documentation on sender IDs.
Ensure the destination phone has signal and isn't blocking messages from unknown senders or shortcodes.
Check for account balance issues on MessageBird. For more information about SMS pricing and delivery, see our MessageBird SMS pricing guide.
Token Verification Errors:
Verification code is incorrect: User entered the wrong code.
Verification has expired: User took too long (default > 30 seconds) to enter the code.
Verification not found or has already been verified: The id might be wrong, or the code was already used successfully.
Performance Issues:
If experiencing latency, check MessageBird service status and monitor API response times.
Implement timeout handling in your API calls to gracefully handle slow responses.
Use connection pooling if making multiple concurrent requests.
Related Resources
Looking to expand your SMS capabilities? Check out these related guides:
Implement 2FA by integrating the MessageBird Verify API into your Node.js/Express app. This involves collecting the user's phone number, sending an OTP via MessageBird's API, and then verifying the code entered by the user against MessageBird's system. Remember to handle errors, logging, and security aspects for a robust solution.
The MessageBird Verify API is a service that allows you to send and verify one-time passwords (OTPs) via SMS or voice calls. It handles OTP generation and security, simplifying the implementation of 2FA in your applications.
MessageBird provides a reliable and easy-to-use API and SDK for sending and verifying OTPs. It integrates seamlessly with Node.js and Express, offering a quick way to implement 2FA with built-in security features.
Error handling is essential from the start. Implement robust error handling for API calls, user input validation, and other potential issues like network problems. This is crucial for a production-ready 2FA system.
Yes, you can customize the SMS message template using the template option in the messagebird.verify.create method. Use the %token placeholder in the message, which MessageBird replaces with the generated OTP.
While a basic regex is provided, it's insufficient. Use a dedicated library like 'google-libphonenumber' to correctly parse and validate phone numbers in E.164 format for reliable international support.
Store your MessageBird API key as an environment variable, loaded using the 'dotenv' library. Never hardcode it in your source code or commit it to version control. Securely manage these credentials in your deployment environment.
Start by creating a project directory, initializing npm, installing required packages (express, express-handlebars, messagebird, dotenv, body-parser), and creating the necessary project file structure. Don't forget to set up your .gitignore file to keep your API keys secure.
Implement rate limiting using middleware like 'express-rate-limit' for both /step2 (OTP requests) and /step3 (OTP verification). This limits requests per phone number/IP address within timeframes to prevent abuse.
This tutorial uses Node.js, Express, the MessageBird Verify API and Node.js SDK, Handlebars, dotenv, and body-parser. It covers setting up the project, configuring the environment, building the Express application, and implementing the OTP flow securely.
This error typically means your MessageBird API key is incorrect or missing. Double-check your .env file and environment variables to ensure the correct Live API key is being used, not the placeholder.
This indicates an incorrect phone number format. Ensure the number is in the international E.164 format (e.g., +1...). Use 'google-libphonenumber' for robust validation to avoid this issue reliably.
Alphanumeric originator IDs are often restricted, especially in regions like the US/Canada. Using a purchased MessageBird virtual number as the originator for your SMS messages is more reliable and often necessary for compliance.
First, consult the MessageBird Dashboard logs for detailed delivery information from carriers. Verify your 'originator' ID's validity for the destination, check the recipient's phone settings and signal, and confirm your MessageBird account balance.
Common errors include incorrect or expired codes, or a code that was already used. Guide the user with specific messages (e.g., "invalid code," "code expired") and suggest appropriate actions like retrying or requesting a new code.
Two-factor authentication (2FA) adds a critical layer of security to user accounts by requiring a password plus a verification code sent to a trusted device. One-time passwords (OTPs) delivered via SMS remain one of the most accessible methods for implementing 2FA in web applications.
⚠️ Security Note: While SMS-based OTP is functional and widely deployed, OWASP security guidelines (2025) identify SMS OTP as less secure than alternative 2FA methods due to vulnerabilities including SIM swapping attacks, SS7 protocol exploits, phishing, and man-in-the-middle attacks. Leading organizations (Microsoft, Twitter) have migrated to more secure alternatives like TOTP authenticator apps, push notifications, or hardware tokens. SMS OTP remains acceptable for low-to-medium security applications but should be combined with additional security measures (rate limiting, device fingerprinting, anomaly detection) for higher-risk use cases. Source: OWASP Multifactor Authentication Cheat Sheet (accessed January 2025)
This guide walks you through building a functional OTP verification flow in a Node.js application using the Express framework and the MessageBird Verify API. You'll build a simple web application that requests a user's phone number, sends a verification code via MessageBird, and validates the code entered by the user.
Project Goals:
Technologies Used:
.envfile.express.urlencoded()andexpress.json(). The standalone body-parser package is no longer required for basic use cases, though it remains available for advanced configurations. This tutorial uses body-parser for clarity but you can replace it with Express built-in methods. Source: Express body-parser middleware documentation (accessed January 2025)System Architecture:
The flow involves these components:
Prerequisites:
1. Setting Up Your Node.js Project
Create your project directory and initialize it.
Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.
mkdir node-messagebird-otp cd node-messagebird-otpInitialize npm: Initialize the project with npm. This creates a
package.jsonfile.The
-yflag accepts the default settings.Install Dependencies: Install Express for the web server, Handlebars for templating, the MessageBird SDK,
dotenvfor environment variables, andbody-parserto handle form submissions.Create Project Structure: Set up a basic directory structure for clarity:
Create these directories manually or use terminal commands:
mkdir -p views/layouts touch views/layouts/main.handlebars views/step1.handlebars views/step2.handlebars views/step3.handlebars touch .env .gitignore index.jsviews/: Contains Handlebars templates.views/layouts/: Contains the main HTML structure template..env: Stores sensitive information like API keys (will be created next)..gitignore: Specifies files/directories Git should ignore (likenode_modulesand.env).index.js: The main application file.Create
.gitignore: Add the following lines to.gitignoreto prevent committing sensitive files and dependencies:2. Configuring MessageBird API Authentication
The application needs your MessageBird API key to authenticate requests. Use
dotenvto manage this securely.Obtain MessageBird API Key:
Create
.envFile: In the root of your project directory (node-messagebird-otp/), create a file named.env.Add API Key to
.env: Add your copied Live API key to the.envfile:Important: Replace
YOUR_LIVE_API_KEY_HEREwith the actual key you copied from the MessageBird dashboard.MESSAGEBIRD_API_KEYvariable holds your secret key.dotenvloads this intoprocess.envso your application can access it without hardcoding it in the source code.3. Building the Express Application
Set up the basic Express server and configure Handlebars.
Create
index.js: Create the main application file,index.js, in the project root.Initial Setup (
index.js): Add the following code toindex.jsto require dependencies and initialize the Express app, MessageBird SDK, and Handlebars:// index.js // 1. Require Dependencies const express = require('express'); const exphbs = require('express-handlebars'); const bodyParser = require('body-parser'); const dotenv = require('dotenv'); // 2. Load Environment Variables dotenv.config(); // This loads the variables from .env into process.env // 3. Initialize MessageBird SDK // Ensure MESSAGEBIRD_API_KEY is set in your .env file if (!process.env.MESSAGEBIRD_API_KEY || process.env.MESSAGEBIRD_API_KEY === 'YOUR_LIVE_API_KEY_HERE') { console.error('Error: MESSAGEBIRD_API_KEY is not set or is still the placeholder in the .env file.'); process.exit(1); // Exit if the API key is missing or not replaced } const messagebird = require('messagebird')(process.env.MESSAGEBIRD_API_KEY); // 4. Initialize Express App const app = express(); const port = process.env.PORT || 8080; // Use port from env or default to 8080 // 5. Configure Handlebars // Note: express-handlebars v6.0+ requires using .engine property // Older versions (v5.x) used: exphbs({ defaultLayout: 'main' }) // Current syntax for v6.0+: exphbs.engine({ defaultLayout: 'main' }) // Source: express-handlebars npm changelog (January 2025) app.engine('handlebars', exphbs.engine({ defaultLayout: 'main' })); app.set('view engine', 'handlebars'); // Specify the views directory (optional if it's named 'views') app.set('views', './views'); // 6. Configure Body Parser Middleware // This is needed to parse data from HTML forms // Alternative for Express 4.16.0+: app.use(express.urlencoded({ extended: true })); app.use(bodyParser.urlencoded({ extended: true })); // 7. Define Routes (We will add these in the next section) // app.get('/', ...); // app.post('/step2', ...); // app.post('/step3', ...); // 8. Start the Server app.listen(port, () => { console.log(`Server listening on http://localhost:${port}`); });dotenv.config()loads the.envfile.process.env. A check ensures the key exists and isn't the placeholder value.body-parsermiddleware to access form data viareq.body.Create Main Layout (
views/layouts/main.handlebars): This file defines the basic HTML structure for all pages. Createviews/layouts/main.handlebarswith the following content:{{{body}}}placeholder is where content from other Handlebars views (step1,step2,step3) will be rendered.4. Implementing SMS OTP Verification Flow
Implement the core logic: requesting the phone number, sending the OTP, and verifying the OTP.
Step 1: Requesting the Phone Number
Create View (
views/step1.handlebars): This view presents a form for the user to enter their phone number. Createviews/step1.handlebars:{{#if error}}...{{/if}}: Conditionally displays an error message if passed from the server.POSTs data to the/step2route.name="number"makes the value accessible asreq.body.numberon the server.type="tel"hints to browsers (especially mobile) that this is a phone number field.Step 2: Sending the Verification Code
When the user submits their phone number, call the MessageBird Verify API to send the OTP.
Create View (
views/step2.handlebars): This view asks the user to enter the code they received. It also includes a hidden field to pass the verificationidto the next step. Createviews/step2.handlebars:name="id"andvalue="{{id}}": Passes the verification ID received from MessageBird.name="token": The input for the user-entered OTP.pattern="\d{6}": Basic HTML5 validation for a 6-digit code.Create Route (
index.js): Add thePOST /step2route inindex.jsto handle the form submission fromstep1:// index.js - Add this route before app.listen() // Route to handle phone number submission and initiate verification app.post('/step2', (req, res) => { const number = req.body.number; // Basic validation (insufficient for production; consider libraries like 'google-libphonenumber' for robust E.164 validation - see Security section) if (!number || !/^\+[1-9]\d{1,14}$/.test(number)) { console.warn(`Invalid phone number format attempt: ${number}`); // Note: This regex is a basic check. Production apps should implement more robust E.164 validation // using libraries like google-libphonenumber (discussed further in the Security section) // to handle various international formats correctly. return res.render('step1', { error: 'Invalid phone number format. Use international format (e.g., +14155552671).' }); } console.log(`Initiating verification for: ${number}`); // Call MessageBird Verify API messagebird.verify.create(number, { // originator: 'VerifyApp', // Sender ID (alpha/numeric, max 11 chars or phone number) // IMPORTANT: Originator ('From' field) requirements vary drastically by country. // Alphanumeric senders (like 'VerifyApp') may require pre-registration or may not work // in regions like the US/Canada. Using a purchased MessageBird virtual number is often more reliable. // Check MessageBird documentation for country-specific Sender ID rules before deploying. originator: process.env.MESSAGEBIRD_ORIGINATOR || 'MessageBird', // Use env var or default template: 'Your verification code is %token.', // %token is replaced by the actual OTP // type: 'sms', // Default is sms // tokenLength: 6, // Default is 6 // timeout: 30 // Default is 30 seconds }, (err, response) => { if (err) { // Handle API errors console.error('MessageBird Verify API error:', err); let errorMessage = 'Failed to send verification code. Try again.'; // Provide more specific feedback if possible, checking safely if (err.errors && err.errors.length > 0 && err.errors[0].description) { errorMessage = err.errors[0].description; } else if (err.statusCode === 401) { errorMessage = 'Authentication failed. Check your API key.' } return res.render('step1', { error: errorMessage }); } // Verification initiated successfully console.log('Verification response:', response); // Log the full response for debugging // Render step 2, passing the verification ID res.render('step2', { id: response.id // Pass the verification ID to the next view }); }); });numberfromreq.body.messagebird.verify.create(number, options, callback)is called.number: The user's phone number.options:originator: The sender ID displayed on the user's phone. Regulations vary. Alphanumeric IDs might require registration or fail in certain countries (e.g., US/Canada). Using a purchased MessageBird number is often safer. Consult MessageBird's country-specific rules. An environment variableMESSAGEBIRD_ORIGINATORis suggested for flexibility.template: The SMS message body.%tokenis mandatory and will be replaced by the generated OTP.timeout(optional): How long the code is valid (default 30 seconds).callback(err, response): Handles the asynchronous response.err, log the error and re-renderstep1with an error message extracted safely fromerr.errors[0].descriptionor a generic message.response), log the response and renderstep2, passingresponse.idto the view. Thisiduniquely identifies this verification attempt.Step 3: Verifying the Code
Take the
idand the user-enteredtokenand ask MessageBird if they match.Create View (
views/step3.handlebars): A simple success message. Createviews/step3.handlebars:Create Route (
index.js): Add thePOST /step3route inindex.jsto handle the submission fromstep2:// index.js - Add this route before app.listen() // Route to handle OTP submission and complete verification app.post('/step3', (req, res) => { const id = req.body.id; // Verification ID from hidden input const token = req.body.token; // OTP entered by the user if (!id || !token) { console.warn('Missing ID or Token in verification attempt.'); // Avoid revealing which one is missing to the user return res.render('step2', { error: 'Verification failed. Try again.', id: id // Pass ID back if available }); } console.log(`Verifying code for ID: ${id}, Token: ${token}`); // Call MessageBird Verify API to verify the token messagebird.verify.verify(id, token, (err, response) => { if (err) { // Verification failed (invalid token, expired, etc.) console.error('MessageBird Verify token error:', err); let errorMessage = 'Verification failed. Check the code and try again.'; // Provide specific API error if available, checking safely if (err.errors && err.errors.length > 0 && err.errors[0].description) { errorMessage = err.errors[0].description + ' Try sending a new code.'; } // Re-render step 2 with error, passing the original ID back return res.render('step2', { error: errorMessage, id: id // Pass ID back so the form still works for retry (if applicable) }); } // Verification successful! console.log('Verification successful:', response); // IMPORTANT: In a real app, you would now update the user's session/state // to indicate successful verification before rendering success. // This example just shows the success page directly. res.render('step3'); }); });idandtokenfromreq.body. Basic check added for their presence.messagebird.verify.verify(id, token, callback)is called.id: The unique ID from theverify.createresponse.token: The OTP code entered by the user.callback(err, response):err, verification failed (wrong code, expired, etc.). Log the error and re-renderstep2with an error message (extracted safely) and the originalid(so the hidden field remains populated).response), the code was correct. Log success and render thestep3success view. A comment highlights that real apps need session updates here.5. Testing Your OTP Application
Your basic OTP verification flow is complete!
Save Files: Ensure all files (
index.js,.env,.gitignore, and all.handlebarsfiles in theviewsdirectories) are saved. Make sure you replaced the placeholder in.env.Start the Server: Open your terminal in the
node-messagebird-otpdirectory and run:You should see:
Server listening on http://localhost:8080. If you see an error about the API key, double-check your.envfile.Test: Open your web browser and navigate to
http://localhost:8080.+14155551234).Common Issues:
PORT=3000in your.envfile and restart.npm installagain to ensure all dependencies are installed..envfile contains the correct Live API key.6. Error Handling and Logging Best Practices
Production applications require robust error handling strategies beyond basic
console.log:WinstonorPino. Configure log levels (debug, info, warn, error) and output formats (JSON works well with log aggregation tools). Log critical events: verification initiation, API errors, successful verification, failed verification attempts.errobject from the MessageBird SDK often contains anerrorsarray (e.g.,err.errors[0]). Useerr.errors[0].codefor specific error types anderr.errors[0].description(accessed safely, as shown) for user-friendly messages. Refer to MessageBird API documentation for error code meanings./step2page. If the code expires or too many attempts fail, guide the user back to/to request a new code.Example Logging Enhancement (Conceptual):
Note: The following snippet is conceptual. Implementing this requires choosing and configuring a specific logging library (e.g., Winston, Pino) and integrating it into your application.
// Conceptual example using a placeholder logger // const logger = require('./logger'); // Assume a configured logger exists // Inside verify.create callback error handling: // logger.error({ // message: 'MessageBird Verify API error during creation', // apiError: err, // Log the full error object for detailed debugging // userNumber: number // Log relevant context (avoid logging sensitive data excessively) // }); // return res.render('step1', { error: userFriendlyMessage }); // Inside verify.verify callback error handling: // logger.warn({ // message: 'MessageBird token verification failed', // verifyId: id, // apiError: err // }); // return res.render('step2', { error: userFriendlyMessage, id: id });7. Security Considerations for SMS Authentication
While MessageBird handles OTP generation and security, consider these points in your application:
^\+[1-9]\d{1,14}$is a start but insufficient for production. Use libraries likegoogle-libphonenumber(npm install google-libphonenumber) for robust parsing and validation according to E.164 standards. Sanitize input to prevent potential injection issues. Learn more about phone number validation best practices.body-parserhelps prevent basic payload manipulation, but always validate input types and lengths on the server./step2endpoint (requesting OTPs): Limit requests per phone number and/or IP address per time window (e.g., 1 request per minute, 5 requests per hour per number)./step3endpoint (verifying OTPs): Limit verification attempts per verification ID and/or IP address per time window (e.g., 5 attempts per 15 minutes per ID).express-rate-limit..envfile or hardcode the API key. Use environment variables managed securely in your deployment environment.express-sessionwith a secure store). After successful OTP verification (Step 3), update the user's server-side session state to grant access or mark the 2FA challenge as complete, rather than just showing a success page. The current example does not implement this critical session component.idvia a hidden form field between steps is simple but relies on client-side state. For higher security, especially in complex flows, consider storing the verification attempt status (e.g., "pending," "verified") server-side, perhaps linked to the user's session or a short-lived database record, instead of solely relying on the client passing theidback correctly./step3helps. You could also implement lockouts after too many failed attempts for a specific verification ID or user account (if integrated with user accounts).csurfto protect your forms from cross-site request forgery attacks.Example Rate Limiting (Conceptual):
Note: The following snippet using
express-rate-limitis conceptual. Install the library (npm install express-rate-limit) and integrate this middleware into your routes.// Conceptual example using express-rate-limit // const rateLimit = require('express-rate-limit'); // // Limit OTP requests per phone number // const otpRequestLimiter = rateLimit({ // windowMs: 15 * 60 * 1000, // 15 minutes // max: 5, // Limit each number to 5 requests per windowMs // message: 'Too many OTP requests from this number, try again after 15 minutes.', // keyGenerator: (req, res) => req.body.number, // Use phone number as key // handler: (req, res, next, options) => { // console.warn(`Rate limit exceeded for OTP request: ${req.body.number}`); // res.status(options.statusCode).render('step1', { error: options.message }); // } // }); // // Limit verification attempts per verification ID // const otpVerifyLimiter = rateLimit({ // windowMs: 10 * 60 * 1000, // 10 minutes // max: 10, // Limit each ID to 10 verification attempts per windowMs // message: 'Too many verification attempts, try again later or request a new code.', // keyGenerator: (req, res) => req.body.id, // Use verification ID as key // handler: (req, res, next, options) => { // console.warn(`Rate limit exceeded for OTP verification: ${req.body.id}`); // // Pass ID back so form still works potentially // res.status(options.statusCode).render('step2', { error: options.message, id: req.body.id }); // } // }); // app.post('/step2', otpRequestLimiter, (req, res) => { /* ... route logic ... */ }); // app.post('/step3', otpVerifyLimiter, (req, res) => { /* ... route logic ... */ });8. Testing Strategies for 2FA Implementation
sinonorjest.mock.supertestto make HTTP requests to your running Express app and assert responses. You would still likely mock the actual MessageBird API calls to avoid sending real SMS messages and incurring costs during tests.9. Deployment Considerations
Environment Variables: Ensure
MESSAGEBIRD_API_KEY(and potentiallyMESSAGEBIRD_ORIGINATOR) is set as an environment variable in your deployment platform (Heroku Config Vars, AWS Secrets Manager, Docker environment variables, etc.). Do not deploy your.envfile.Platform: Choose a hosting platform (Heroku, AWS EC2/ECS/Lambda, Google Cloud Run, DigitalOcean App Platform, etc.). Follow their specific Node.js deployment guides.
package.jsonScripts: Add astartscript to yourpackage.json:// package.json "scripts": { "start": "node index.js", "test": "echo \"Error: no test specified\" && exit 1" },Most platforms use
npm startto run your application.HTTPS: Configure HTTPS on your deployment platform or via a load balancer/proxy.
CI/CD (Continuous Integration/Continuous Deployment): Set up a pipeline (GitHub Actions, GitLab CI, Jenkins) to automatically test and deploy your application when changes are pushed to your repository.
10. Troubleshooting Common Issues
MESSAGEBIRD_API_KEY. Double-check the key in your environment variables/.envfile and ensure it's a Live key and not the placeholder.+1...). Use robust validation (Section 7).originatorbeing used is valid for the destination country. Alphanumeric senders are restricted in some regions (like the US/Canada) and may require pre-registration. Try using a purchased MessageBird virtual number as the originator if allowed. Check MessageBird's documentation on sender IDs.Verification code is incorrect: User entered the wrong code.Verification has expired: User took too long (default > 30 seconds) to enter the code.Verification not found or has already been verified: Theidmight be wrong, or the code was already used successfully.Related Resources
Looking to expand your SMS capabilities? Check out these related guides: