This guide provides a step-by-step walkthrough for building a secure One-Time Password (OTP) verification system, often used for Two-Factor Authentication (2FA), within a Node.js application using the Express framework and the MessageBird Verify API. We'll cover everything from project setup to deployment considerations.
By the end of this tutorial, you will have a functional web application that can:
- Prompt a user for their phone number.
- Send an OTP via SMS using MessageBird.
- Verify the OTP entered by the user.
This enhances application security by adding a second verification factor beyond just a password, confirming the user possesses the registered phone number.
Technologies Used:
- Node.js: A JavaScript runtime environment.
- Express.js: A minimal and flexible Node.js web application framework.
- MessageBird Node.js SDK: To interact with the MessageBird Verify API.
- Handlebars: A simple templating engine for rendering HTML views.
- dotenv: To manage environment variables securely.
- body-parser: Middleware to parse incoming request bodies.
Prerequisites:
- Node.js and npm (or yarn) installed.
- A MessageBird account with a Live API Key. Test keys will not work with the Verify API for sending OTPs.
- A phone number capable of receiving SMS messages for testing.
- Basic understanding of Node.js, Express, and asynchronous JavaScript.
System Architecture:
The flow involves three main components:
- User's Browser: Interacts with the web application, submitting the phone number and OTP.
- Node.js/Express Server: Handles HTTP requests, manages application logic, interacts with the MessageBird API using the SDK, and renders HTML views.
- MessageBird Verify API: Generates and sends the OTP via SMS, and verifies the token submitted by the user.
1. Setting up the project
Let's start by creating our project directory and installing the necessary dependencies.
-
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-otp
-
Initialize Node.js Project: Initialize the project using npm, which creates a
package.json
file. The-y
flag accepts default settings.npm init -y
-
Install Dependencies: Install Express, the MessageBird SDK, Handlebars for templating,
dotenv
for environment variables, andbody-parser
for handling form data.npm install express messagebird express-handlebars dotenv body-parser
-
Create Project Structure: Set up a basic directory structure for views and our main application file.
mkdir views mkdir views/layouts touch index.js touch .env touch .env.example touch views/layouts/main.handlebars touch views/step1.handlebars touch views/step2.handlebars touch views/step3.handlebars
Your structure should now look like this:
node-messagebird-otp/ ├── node_modules/ ├── views/ │ ├── layouts/ │ │ └── main.handlebars │ ├── step1.handlebars │ ├── step2.handlebars │ └── step3.handlebars ├── .env ├── .env.example ├── index.js ├── package-lock.json └── package.json
-
Configure Environment Variables: The MessageBird API key is sensitive and should not be hardcoded. We'll use
dotenv
to load it from a.env
file.-
Get your MessageBird Live API Key:
- Log in to your MessageBird Dashboard.
- Navigate to the Developers section in the left-hand menu.
- Click on the API access tab.
- If you don't have a Live key, create one. Copy the Live API Key. Note: Verify API requires a live key.
-
Add the key to
.env
: Open the.env
file and add your key:MESSAGEBIRD_API_KEY=YOUR_LIVE_API_KEY
Replace
YOUR_LIVE_API_KEY
with the actual key you copied. -
Add a template to
.env.example
: It's good practice to have an example file that shows required variables without exposing secrets. Add this to.env.example
:MESSAGEBIRD_API_KEY=your_messagebird_live_api_key_here
Add
.env
to your.gitignore
file (if using Git) to prevent committing secrets.
-
2. Implementing Core Functionality
Now, let's build the Express application logic in index.js
and create the corresponding Handlebars views.
-
Set up
index.js
: Openindex.js
and add the initial setup code:// index.js // 1. Import dependencies const express = require('express'); const exphbs = require('express-handlebars'); const bodyParser = require('body-parser'); require('dotenv').config(); // Load environment variables from .env file // 2. Initialize MessageBird SDK // Ensure you have set MESSAGEBIRD_API_KEY in your .env file const messagebirdApiKey = process.env.MESSAGEBIRD_API_KEY; if (!messagebirdApiKey) { console.error(""Error: MESSAGEBIRD_API_KEY is not set in the environment variables.""); process.exit(1); // Exit if the API key is missing } const messagebird = require('messagebird')(messagebirdApiKey); // 3. Initialize Express app and Middleware const app = express(); app.engine('handlebars', exphbs.engine({ defaultLayout: 'main' })); // Corrected initialization for express-handlebars v6+ app.set('view engine', 'handlebars'); app.use(bodyParser.urlencoded({ extended: true })); // Parse URL-encoded bodies // 4. Define Port const PORT = process.env.PORT || 3000; // Use port from env or default to 3000 // --- Routes will be added below --- // 5. Start the server app.listen(PORT, () => { console.log(`Server listening on http://localhost:${PORT}`); });
- Why
dotenv.config()
first? It needs to run early to load environment variables before they are used, especially the API key for the MessageBird SDK initialization. - Why
bodyParser
? It parses the data submitted from HTML forms (application/x-www-form-urlencoded
) and makes it available inreq.body
. - Why
express-handlebars
? It allows us to use Handlebars templates to dynamically generate HTML pages, separating presentation from logic.
- Why
-
Create Main Layout (
views/layouts/main.handlebars
): This file defines the basic HTML structure shared by all pages.<!DOCTYPE html> <html> <head> <meta charset=""utf-8""> <meta name=""viewport"" content=""width=device-width_ initial-scale=1""> <title>MessageBird OTP Verification</title> <style> body { font-family: sans-serif; padding: 20px; } .container { max-width: 500px; margin: auto; border: 1px solid #ccc; padding: 20px; border-radius: 8px; } h1 { text-align: center; color: #0575E6; } /* MessageBird blue */ label, input { display: block; margin-bottom: 10px; width: 95%; } input[type=""tel""], input[type=""text""] { padding: 10px; border: 1px solid #ccc; border-radius: 4px; } input[type=""submit""] { background-color: #0575E6; color: white; padding: 10px 15px; border: none; border-radius: 4px; cursor: pointer; width: auto; } input[type=""submit""]:hover { background-color: #045db2; } .error { color: red; border: 1px solid red; padding: 10px; margin-bottom: 15px; border-radius: 4px; background-color: #ffebeb; } .success { color: green; border: 1px solid green; padding: 10px; margin-bottom: 15px; border-radius: 4px; background-color: #e6ffed; } p { line-height: 1.6; } </style> </head> <body> <div class=""container""> <h1>MessageBird OTP Verification</h1> </div> </body> </html>
-
Step 1: Request Phone Number (
views/step1.handlebars
) This view displays the form for the user to enter their phone number.<h2>Step 1: Enter Your Phone Number</h2> <div class=""error""> </div> <p>Please enter your phone number in international format (e.g., +14155552671) to receive a verification code via SMS.</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>
{{#if error}}
: Conditionally displays an error message if theerror
variable is passed to the template.action=""/send-otp""
: Specifies that the form data will be sent via HTTP POST to the/send-otp
route.type=""tel""
: Helps mobile browsers display a numeric keypad.
-
Add Route for Step 1 (GET
/
) Add this route toindex.js
before theapp.listen()
call.// index.js (inside the routes section) // Route to display the initial form (Step 1) app.get('/', (req, res) => { res.render('step1'); // Renders views/step1.handlebars });
-
Step 2: Send OTP and Request Code (
views/step2.handlebars
) This view asks the user to enter the OTP they received. It includes a hidden field to pass the MessageBird verification ID.<h2>Step 2: Enter Verification Code</h2> <div class=""error""> </div> <p>We have sent a verification code via SMS to your phone number.</p> <p>Please enter the 6-digit code below:</p> <form method=""post"" action=""/verify-otp""> <input type=""hidden"" name=""id"" value=""> "" / <label for=""token"">Verification Code:</label> <input type=""text"" id=""token"" name=""token"" required pattern=""\d{6}"" title=""Please enter the 6-digit code"" /> <input type=""submit"" value=""Verify Code"" /> </form> <p><a href="" />Try a different number?</a></p>
input type=""hidden"" name=""id""
: Stores the verification ID received from MessageBird. This is crucial for the next step. It's hidden because the user doesn't need to see it, but the server needs it to link the token back to the original request.pattern=""\d{6}""
: Basic HTML5 validation for a 6-digit number.
-
Add Route for Sending OTP (POST
/send-otp
) This route handles the phone number submission, calls the MessageBird API to send the OTP, and renders the code entry form. Add this toindex.js
.// index.js (inside the routes section) // Route to handle phone number submission and send OTP (Step 2) app.post('/send-otp', (req, res) => { const number = req.body.number; // Basic validation (more robust validation recommended for production) if (!number || !/^\+[1-9]\d{1,14}$/.test(number)) { return res.render('step1', { error: 'Please enter a valid phone number in international format (e.g., +14155552671).' }); } const params = { originator: 'VerifyApp', // Can be your app name (up to 11 alphanumeric chars) or a verified number template: 'Your verification code is %token.', // Message template // type: 'sms', // Default is sms, can be 'tts' for voice call // timeout: 60, // Validity period in seconds (default 30) // tokenLength: 6, // Default is 6 }; console.log(`Sending verification to ${number}`); messagebird.verify.create(number, params, (err, response) => { if (err) { // Handle API errors console.error(""MessageBird API Error:"", err); let userErrorMessage = 'Failed to send verification code. Please try again later.'; if (err.errors && err.errors.length > 0) { // Provide more specific feedback if available const firstError = err.errors[0]; if (firstError.code === 21) { // Code for invalid recipient userErrorMessage = 'The phone number provided is invalid or not reachable.'; } else { userErrorMessage = `Error: ${firstError.description}`; } } return res.render('step1', { error: userErrorMessage }); } // Successfully initiated verification console.log(""Verification Response:"", response); // Render step 2, passing the verification ID res.render('step2', { id: response.id }); }); });
- Input Validation: Includes a basic regex check for international phone number format. Production apps should use more robust validation (e.g.,
libphonenumber-js
). messagebird.verify.create(number, params, callback)
: This is the core call.number
: The recipient's phone number.params
: An object containing options.originator
: The sender ID displayed on the user's phone. Must be alphanumeric (max 11 chars) or a verified MessageBird number. Caveat: Alphanumeric originators are not supported in all countries (e.g., USA). Using a purchased virtual number is often more reliable globally.template
: The message text.%token
is replaced by the OTP.- Other options (
type
,timeout
,tokenLength
) can customize the behavior.
callback(err, response)
: Handles the asynchronous response.- If
err
: An error occurred (e.g., invalid number, API key issue). Log the detailed error (console.error
) and show a user-friendly message. - If
response
: The request was successful. Theresponse.id
is the unique identifier for this verification attempt. We pass it to the next step (step2.handlebars
).
- If
- Input Validation: Includes a basic regex check for international phone number format. Production apps should use more robust validation (e.g.,
-
Step 3: Verification Result (
views/step3.handlebars
) This view shows a success message upon correct verification.<h2>Step 3: Verification Successful!</h2> <div class=""success""> <p>Congratulations! Your phone number has been successfully verified.</p> </div> <p><a href="" />Start Over</a></p>
-
Add Route for Verifying OTP (POST
/verify-otp
) This route takes the verification ID and the user-entered token, calls MessageBird to verify them, and renders the success or failure view. Add this toindex.js
.// index.js (inside the routes section) // Route to handle OTP submission and verify (Step 3) app.post('/verify-otp', (req, res) => { const id = req.body.id; const token = req.body.token; if (!id || !token) { // Should ideally not happen if form is submitted correctly return res.render('step1', { error: 'Missing verification ID or token.' }); } console.log(`Verifying token ${token} for ID ${id}`); messagebird.verify.verify(id, token, (err, response) => { if (err) { // Verification failed (invalid token, expired, etc.) console.error(""Verification Error:"", err); let userErrorMessage = 'Verification failed. Please check the code and try again.'; if (err.errors && err.errors.length > 0) { const firstError = err.errors[0]; // Common error code 20 is ""verify request could not be found or has been verified already"" // Common error code 23 is ""token is invalid"" if (firstError.code === 23 || (firstError.description && firstError.description.includes('expired'))) { userErrorMessage = 'The code entered is incorrect or has expired. Please request a new code.'; // Redirecting to start might be better UX for expired tokens // return res.render('step1', { error: userErrorMessage }); } else { userErrorMessage = `Error: ${firstError.description}`; } } // Re-render step 2 with an error, passing the original ID back return res.render('step2', { error: userErrorMessage, id: id }); } // Verification successful! console.log(""Verification Success Response:"", response); // Render the success page res.render('step3'); }); });
messagebird.verify.verify(id, token, callback)
: The key API call here.id
: The verification ID received from thecreate
call (passed via the hidden form field).token
: The OTP entered by the user.callback(err, response)
:- If
err
: The token was incorrect, expired, or the ID was invalid. Renderstep2
again with an error message, making sure to pass theid
back so the user can retry with the same verification attempt (if it hasn't expired). - If
response
: The token was correct! Render the success page (step3
). In a real application, you would now mark the user's phone number as verified in your database or proceed with login/action.
- If
3. Error Handling and Logging
We've included basic error handling and logging:
- API Errors: Catches errors from
messagebird.verify.create
andmessagebird.verify.verify
. Logs detailed errors to the console (console.error
) for debugging. Displays user-friendly messages on the appropriate page (step1
orstep2
). Specific error codes (like 21 for invalid recipient, 23 for invalid token) are checked to provide better user feedback. - Input Validation: Basic validation for phone number format in
/send-otp
. - Logging:
console.log
andconsole.error
statements track the flow and errors. For production, consider using a dedicated logging library (like Winston or Pino) to structure logs, write to files, and set different log levels.
4. Security Considerations
- API Key Security: The API key is loaded from
.env
and should never be committed to version control. Ensure.env
is in your.gitignore
. In production, use your platform's secure environment variable management. - Input Validation: Sanitize and validate all user inputs. The phone number validation is basic; use libraries like
libphonenumber-js
for robust checking. Validate the token format (e.g., ensure it's numeric and the expected length). - Rate Limiting: Protect the
/send-otp
endpoint against abuse. Someone could repeatedly request codes, costing you money and potentially harassing users. Implement rate limiting per IP address or user account using middleware likeexpress-rate-limit
. Consider also applying rate limiting (perhaps stricter) to the/verify-otp
endpoint to prevent attackers from rapidly guessing tokens for a known verification ID.npm install express-rate-limit
// index.js (add near the top with other requires/middleware) const rateLimit = require('express-rate-limit'); const otpLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // Limit each IP to 5 OTP requests per windowMs message: 'Too many requests, please try again after 15 minutes', standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers }); // Apply the rate limiting middleware to OTP sending route app.use('/send-otp', otpLimiter); // You might add another limiter for /verify-otp: // const verifyLimiter = rateLimit({ windowMs: 5 * 60 * 1000, max: 10, message: 'Too many verification attempts.' }); // app.use('/verify-otp', verifyLimiter);
- HTTPS: Always use HTTPS in production to encrypt communication between the client and server.
- Session Management: In a real application integrating this into user accounts, use secure session management. Don't rely solely on hidden form fields for sensitive state like the verification ID between requests if it's tied to a logged-in user; store it server-side in the user's session.
5. Testing and Verification
-
Start the Application: Ensure your
.env
file has the correctMESSAGEBIRD_API_KEY
. Run the application from your terminal:node index.js
You should see
Server listening on http://localhost:3000
. -
Manual Browser Testing:
- Open your web browser and navigate to
http://localhost:3000
. - You should see the "Step 1: Enter Your Phone Number" form.
- Enter your phone number in the correct international format (e.g.,
+1XXXYYYZZZZ
). Click "Send Code". - If successful, you should see the "Step 2: Enter Verification Code" form. Check your phone for an SMS containing the code (it might take a few seconds).
- Enter the 6-digit code you received. Click "Verify Code".
- If the code is correct and hasn't expired, you should see the "Step 3: Verification Successful!" page.
- Test Error Cases: Try entering an invalid phone number format, an incorrect OTP, or wait longer than the timeout (default 30s) before entering the code to see the error messages.
- Open your web browser and navigate to
-
API Endpoint Testing (Optional - using
curl
): You can also test the POST endpoints directly.-
Send OTP:
# Replace +1XXXYYYZZZZ with a valid number curl -X POST http://localhost:3000/send-otp \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "number=+1XXXYYYZZZZ" \ --verbose
Look for the HTML response containing the Step 2 form and the hidden
id
field. Extract theid
value. -
Verify OTP:
# Replace YOUR_VERIFICATION_ID and RECEIVED_OTP with actual values curl -X POST http://localhost:3000/verify-otp \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "id=YOUR_VERIFICATION_ID&token=RECEIVED_OTP" \ --verbose
Look for the HTML response for Step 3 (success) or Step 2 (failure).
-
6. Enhancements and Next Steps
- Integrate with User Accounts: Store the verification status (
isPhoneNumberVerified: true
) in your user database upon successful verification. - Session Management: Store the
verificationId
in a server-side session instead of a hidden field for better security, especially if linking verification to a logged-in user. - Voice (TTS) OTP: Change
params.type
to'tts'
in themessagebird.verify.create
call to send the OTP via a voice call instead of SMS. - Customization: Explore other
verify.create
parameters liketimeout
,tokenLength
,language
, andvoice
(for TTS). - UI/UX: Improve the user interface and provide clearer feedback during the process.
- Resend Code: Add functionality to allow users to request the code again after a certain delay.
7. Troubleshooting and Caveats
- Invalid API Key: Ensure
MESSAGEBIRD_API_KEY
in.env
is correct and is a Live key. Check for typos or extra spaces. Error messages might mention authentication failure. - Invalid Phone Number: MessageBird requires numbers in E.164 international format (e.g.,
+14155552671
). Ensure the '+' and country code are included. API error code 21 often indicates this. - Originator Restrictions: Alphanumeric sender IDs (e.g., 'VerifyApp') are not supported in all countries (like the US/Canada). In these regions, you must use a purchased MessageBird virtual number or a shortcode as the originator. Test with a simple numeric originator (like your MessageBird number) if alphanumeric ones fail.
- Token Expired/Invalid: Tokens have a limited validity (
timeout
, default 30s). If the user takes too long, verification will fail (often error code 20 or 23). Ensure the token entered exactly matches the one received. - Message Delivery Issues: SMS delivery can sometimes be delayed or fail due to carrier issues. Consider implementing a "Resend Code" option or offering Voice OTP as a fallback.
- Rate Limits: If you send too many requests quickly, MessageBird might temporarily block you. Implement client-side and server-side rate limiting (as shown in Security).
- MessageBird Balance: Ensure your MessageBird account has sufficient balance to send messages/make calls.
8. Deployment Considerations
- Environment Variables: Never hardcode API keys. Use your deployment platform's system for managing environment variables (e.g., Heroku Config Vars, AWS Secrets Manager, Docker environment variables). Do not deploy your
.env
file. - HTTPS: Configure your server (e.g., using Nginx or your platform's load balancer) to use HTTPS.
- Process Management: Use a process manager like PM2 or Nodemon (for development) to keep your Node.js application running reliably and handle restarts.
- Logging: Configure a robust logging solution suitable for production monitoring.
- Dependencies: Ensure all production dependencies are listed in
package.json
(not justdevDependencies
). Runnpm install --production
in your deployment environment.
9. Complete Code Repository
A complete, runnable version of this project can be found on GitHub.
(Note: You would need to add the actual link to the repository containing this code here.)
You now have a functional Node.js application capable of performing SMS OTP verification using the MessageBird Verify API. This provides a solid foundation for enhancing the security of your web applications with two-factor authentication. Remember to adapt the error handling, security measures, and UI/UX to fit the specific needs of your production environment.