This guide provides a step-by-step walkthrough for integrating MessageBird's Verify API into a Next.js application to implement robust One-Time Password (OTP) or Two-Factor Authentication (2FA) using SMS. We will build a complete flow from requesting an OTP to verifying it, focusing on best practices for security, error handling, and user experience.
By the end of this guide, you will have a functional Next.js application capable of sending OTPs via MessageBird and verifying user input, laying the foundation for secure user authentication or phone number verification processes.
Project Overview and Goals
Goal: To add SMS-based OTP verification to a Next.js application using MessageBird's Verify API.
Problem Solved: This implementation enhances security by adding a second factor of authentication or verifies user phone numbers, reducing fraudulent sign-ups and securing user accounts against unauthorized access.
Technologies Used:
- Next.js: A React framework for building server-rendered or statically generated web applications. We'll use its API Routes feature for backend logic.
- Node.js: The runtime environment for our Next.js backend API routes.
- MessageBird Verify API: The third-party service used to send and verify OTPs via SMS.
- MessageBird Node.js SDK: Simplifies interaction with the MessageBird API.
- React: For building the user interface components.
- Prisma (Optional but Recommended): For database interaction if you need to persist user 2FA status or phone numbers. This guide focuses on the core OTP flow but includes notes on Prisma integration.
- Tailwind CSS (Optional): For basic styling of UI components.
Architecture:
(Note: An architecture diagram image (e.g., PNG/SVG) would ideally be placed here for better visualization across different platforms.)
- The user enters their phone number into the Next.js frontend UI.
- The frontend sends the phone number to a Next.js API Route (
/api/auth/request-otp
). - The API Route uses the MessageBird Node.js SDK to call the MessageBird Verify API, requesting an OTP be sent to the user's number.
- MessageBird sends the OTP via SMS and returns a verification
id
to the API Route. - The API Route (potentially) stores this
id
temporarily (e.g., in session, encrypted cookie, or returns it to the client – careful with security implications) and signals success to the frontend. - The frontend prompts the user to enter the received OTP code.
- The user enters the code, which the frontend sends along with the verification
id
to another API Route (/api/auth/verify-otp
). - This API Route uses the MessageBird SDK to call the Verify API again, providing the
id
and the user'stoken
(the OTP code). - MessageBird checks if the token is correct for the given
id
and responds to the API Route. - The API Route processes the response (success or failure) and informs the frontend. If successful, it might update the user's profile in the database (e.g., set
isTwoFactorEnabled = true
).
Prerequisites:
- Node.js (v18 or later recommended) and npm/yarn installed.
- A MessageBird account with a Live API Key.
- Basic understanding of React and Next.js.
- (Optional) PostgreSQL database and connection string if using Prisma.
- (Optional) Familiarity with Tailwind CSS for styling.
Setting up the Project
Let's initialize a new Next.js project and install the necessary dependencies.
-
Create Next.js App
Open your terminal and run the following command, choosing options like TypeScript (recommended), Tailwind CSS (optional), and App Router (recommended):
npx create-next-app@latest messagebird-otp-nextjs cd messagebird-otp-nextjs
Follow the prompts. We'll assume you're using the App Router and TypeScript.
-
Install Dependencies
Install the MessageBird Node.js SDK:
npm install messagebird
(Optional) If you plan to store user data or 2FA status, install Prisma:
npm install prisma @prisma/client npm install -D prisma npx prisma init --datasource-provider postgresql # Or your preferred DB
-
Configure Environment Variables
Create a
.env.local
file in the root of your project. Never commit this file to version control. Add your MessageBird Live API Key and (if using Prisma) your database connection string.# .env.local # MessageBird API Key (Get from MessageBird Dashboard > Developers > API access) MESSAGEBIRD_API_KEY=""YOUR_LIVE_API_KEY_HERE"" # (Optional) Database URL for Prisma # Example for PostgreSQL: postgresql://USER:PASSWORD@HOST:PORT/DATABASE DATABASE_URL=""YOUR_DATABASE_CONNECTION_STRING""
MESSAGEBIRD_API_KEY
: Obtain this from your MessageBird Dashboard under Developers > API access (REST). Ensure you are using a Live key for actual SMS sending. Test keys won't send real messages.DATABASE_URL
: Your database connection string. Format depends on your database provider.
-
(Optional) Prisma Schema Setup
If using Prisma, modify the generated
prisma/schema.prisma
file to include relevant user fields for 2FA.// prisma/schema.prisma generator client { provider = ""prisma-client-js"" } datasource db { provider = ""postgresql"" // Or your chosen provider url = env(""DATABASE_URL"") } model User { id String @id @default(cuid()) email String? @unique // Example field phoneNumber String? @unique // Store verified phone number isTwoFactorEnabled Boolean @default(false) // Flag for 2FA status createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Add other user fields as needed }
Apply the schema to your database:
npx prisma db push # You might also need: npx prisma generate
This creates the
User
table in your database.
Implementing Core Functionality (API Routes)
We'll create two API routes within the app/api/
directory: one to request the OTP and one to verify it.
-
Create Helper for MessageBird Client
It's good practice to initialize the MessageBird client once. Create
lib/messagebird.ts
:// lib/messagebird.ts import messagebird from 'messagebird'; const apiKey = process.env.MESSAGEBIRD_API_KEY; if (!apiKey) { throw new Error('MESSAGEBIRD_API_KEY environment variable is not set.'); } // Initialize with your API key export const mbClient = messagebird(apiKey);
-
API Route to Request OTP (
/api/auth/request-otp
)Create the file
app/api/auth/request-otp/route.ts
:// app/api/auth/request-otp/route.ts import { NextRequest, NextResponse } from 'next/server'; import { mbClient } from '@/lib/messagebird'; // Adjust path if needed export async function POST(req: NextRequest) { try { const body = await req.json(); const { phoneNumber } = body; // Basic validation (E.164 format). Consider using a library like // 'google-libphonenumber' for more robust validation in production. if (!phoneNumber || typeof phoneNumber !== 'string' || !/^\+[1-9]\d{1,14}$/.test(phoneNumber)) { return NextResponse.json({ error: 'Invalid phone number format. Use E.164 format (e.g., +1234567890).' }, { status: 400 }); } // Parameters for the MessageBird Verify API const params = { // Sender ID: Alphanumeric (max 11 chars) or phone number. Note: Alphanumeric IDs // are not supported in all countries (e.g., US/Canada). Using a purchased virtual // number from MessageBird is often the most reliable method globally for a custom ID. originator: 'VerifyApp', type: 'sms', // Use 'tts' for voice call OTP template: 'Your verification code is %token.', // Message template // tokenLength: 6, // Default is 6 // timeout: 60, // Default is 30 seconds }; // Note: This uses a Promise wrapper for the callback-based SDK method. // Check if the current 'messagebird' SDK version supports native Promises // (e.g., `await mbClient.verify.create(...)`) for potentially cleaner async/await syntax. return new Promise((resolve) => { mbClient.verify.create(phoneNumber, params, (err: any, response: any) => { if (err) { console.error(""MessageBird Verify Create Error:"", err); // Provide more specific error messages based on err.errors let errorMessage = 'Failed to send verification code.'; if (err.errors && err.errors[0]) { errorMessage = err.errors[0].description || errorMessage; // Handle specific errors like invalid number format explicitly if needed if (err.errors[0].code === 21) { // Example: Code for invalid parameter errorMessage = 'Invalid phone number provided to MessageBird.'; } } resolve(NextResponse.json({ error: errorMessage }, { status: 500 })); } else { console.log(""MessageBird Verify Create Response:"", response); // IMPORTANT: Securely handle the response.id // For this example, we return it. In production, consider: // 1. Storing it in a secure, httpOnly cookie associated with the user's session. // 2. Storing it server-side (e.g., Redis, database) linked to the session ID. // Returning it directly to the client is simpler but less secure if not handled carefully frontend. resolve(NextResponse.json({ success: true, verifyId: response.id }, { status: 200 })); } }); }); } catch (error) { console.error(""Request OTP Error:"", error); return NextResponse.json({ error: 'An unexpected error occurred.' }, { status: 500 }); } }
- Validation: Includes basic E.164 format check. Added note about using
google-libphonenumber
for production. mbClient.verify.create
: Calls the MessageBird API.phoneNumber
: The user's number in international E.164 format (e.g.,+14155552671
).params
: Configuration options.originator
: Updated explanation regarding alphanumeric IDs and virtual numbers for global reliability.template
: The message text.%token
is replaced by the generated OTP.timeout
: How long the OTP is valid (default 30s).
- Error Handling: Logs errors and returns a user-friendly message. Parses MessageBird-specific errors if available.
- Success Response: Returns
success: true
and theverifyId
. Crucially, handling thisverifyId
securely is vital. See comments in the code. - Promise Wrapper Note: Added comment about potentially using native
async/await
if supported by the SDK version.
- Validation: Includes basic E.164 format check. Added note about using
-
API Route to Verify OTP (
/api/auth/verify-otp
)Create the file
app/api/auth/verify-otp/route.ts
:// app/api/auth/verify-otp/route.ts import { NextRequest, NextResponse } from 'next/server'; import { mbClient } from '@/lib/messagebird'; // Adjust path // import { prisma } from '@/lib/prisma'; // Uncomment if using Prisma export async function POST(req: NextRequest) { try { const body = await req.json(); const { verifyId, token } = body; // Basic validation if (!verifyId || typeof verifyId !== 'string' || verifyId.trim() === '') { return NextResponse.json({ error: 'Verification ID is missing.' }, { status: 400 }); } if (!token || typeof token !== 'string' || !/^\d{6}$/.test(token)) { // Assuming 6-digit token return NextResponse.json({ error: 'Invalid OTP format. Must be 6 digits.' }, { status: 400 }); } // Note: This uses a Promise wrapper for the callback-based SDK method. // Check if the current 'messagebird' SDK version supports native Promises // (e.g., `await mbClient.verify.verify(...)`) for potentially cleaner async/await syntax. return new Promise((resolve) => { mbClient.verify.verify(verifyId, token, (err: any, response: any) => { if (err) { console.error(""MessageBird Verify Verify Error:"", err); let errorMessage = 'Failed to verify code.'; if (err.errors && err.errors[0]) { errorMessage = err.errors[0].description || errorMessage; // Example: Handle specific error descriptions like expired/invalid ID if (err.errors[0].code === 10) { // Example code often associated with 'not_found' (expired/invalid ID) errorMessage = 'Verification ID is invalid or has expired.'; } // Check err.errors[0].description for more specific details from MessageBird } // Common error: Token is incorrect or expired. resolve(NextResponse.json({ error: errorMessage, verified: false }, { status: 400 })); } else { // Verification successful! console.log(""MessageBird Verify Verify Response:"", response); // Optional: Update user record in database if verification is for enabling 2FA // const userId = ''; // Get userId from session/token // try { // await prisma.user.update({ // where: { id: userId }, // data: { isTwoFactorEnabled: true, phoneNumber: response.recipient }, // Store verified number // }); // } catch (dbError) { // console.error(""Database update error:"", dbError); // // Decide how to handle DB error - maybe verification still counts? // resolve(NextResponse.json({ error: 'Verification successful, but failed to update profile.' }, { status: 500 })); // return; // } // Clear the verifyId from session/cookie here if stored server-side resolve(NextResponse.json({ success: true, verified: true }, { status: 200 })); } }); }); } catch (error) { console.error(""Verify OTP Error:"", error); return NextResponse.json({ error: 'An unexpected error occurred.', verified: false }, { status: 500 }); } }
- Validation: Checks for
verifyId
and basic OTP format (assuming 6 digits). mbClient.verify.verify
: Calls MessageBird to check the code.verifyId
: The ID received from thecreate
call.token
: The 6-digit code entered by the user.
- Error Handling: Handles cases like incorrect token, expired token/ID, or other API errors. Added note to check
err.errors[0].description
for specifics. - Success Response: Returns
verified: true
. - (Optional) Database Update: Includes commented-out code showing how you might update a user record in Prisma upon successful verification (e.g., enabling 2FA or storing the verified phone number). You would need to get the
userId
from the user's session or authentication token. - Promise Wrapper Note: Added comment about potentially using native
async/await
if supported by the SDK version.
- Validation: Checks for
Frontend Implementation (UI Components)
Now, let's build simple React components for the user interface on a page.
-
Create the Page
Create a page file, for example,
app/verify/page.tsx
:// app/verify/page.tsx 'use client'; // This component interacts with user state and browser APIs import React, { useState } from 'react'; export default function VerifyPage() { const [phoneNumber, setPhoneNumber] = useState(''); const [otpCode, setOtpCode] = useState(''); // SECURITY WARNING: Storing verifyId in client-side state is simple for demos but NOT recommended for production. // See Security Considerations section for better approaches (httpOnly cookies, server sessions). const [verifyId, setVerifyId] = useState<string | null>(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null); const [isOtpSent, setIsOtpSent] = useState(false); const [isVerified, setIsVerified] = useState(false); const handleRequestOtp = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); setError(null); setMessage(null); setIsOtpSent(false); setVerifyId(null); // Clear previous ID try { const res = await fetch('/api/auth/request-otp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phoneNumber }), }); const data = await res.json(); if (!res.ok) { throw new Error(data.error || 'Failed to request OTP'); } setVerifyId(data.verifyId); // Store the ID from the backend (see security warning above) setIsOtpSent(true); setMessage('OTP sent successfully! Please check your phone.'); } catch (err: any) { setError(err.message || 'An error occurred.'); } finally { setIsLoading(false); } }; const handleVerifyOtp = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); setError(null); setMessage(null); if (!verifyId) { setError(""Verification process not initiated correctly. Please request OTP again.""); setIsLoading(false); return; } try { const res = await fetch('/api/auth/verify-otp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, // Send the stored verifyId and the user's input code body: JSON.stringify({ verifyId: verifyId, token: otpCode }), }); const data = await res.json(); if (!res.ok || !data.verified) { throw new Error(data.error || 'Failed to verify OTP'); } setIsVerified(true); setMessage('Phone number verified successfully!'); // Reset form state after success setIsOtpSent(false); setVerifyId(null); setOtpCode(''); // Optionally redirect or update UI further } catch (err: any) { setError(err.message || 'An error occurred during verification.'); // Keep OTP form visible on verification failure to allow retry } finally { setIsLoading(false); } }; // Basic styling with Tailwind CSS (optional) const inputStyle = ""border p-2 rounded w-full mb-4""; const buttonStyle = ""bg-blue-500 text-white p-2 rounded hover:bg-blue-600 disabled:opacity-50""; return ( <div className=""max-w-md mx-auto mt-10 p-6 border rounded shadow""> <h1 className=""text-2xl font-bold mb-6 text-center"">Verify Phone Number</h1> {error && <p className=""text-red-500 mb-4 bg-red-100 p-3 rounded"">{error}</p>} {message && <p className=""text-green-500 mb-4 bg-green-100 p-3 rounded"">{message}</p>} {!isOtpSent && !isVerified && ( <form onSubmit={handleRequestOtp}> <label htmlFor=""phoneNumber"" className=""block mb-2 font-medium"">Phone Number (E.164 format)</label> <input type=""tel"" id=""phoneNumber"" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} placeholder=""+1234567890"" required className={inputStyle} disabled={isLoading} /> <button type=""submit"" disabled={isLoading || !phoneNumber} className={`${buttonStyle} w-full`}> {isLoading ? 'Sending...' : 'Send OTP'} </button> </form> )} {isOtpSent && !isVerified && ( <form onSubmit={handleVerifyOtp}> <p className=""mb-4"">Enter the 6-digit code sent to {phoneNumber}:</p> <label htmlFor=""otpCode"" className=""block mb-2 font-medium"">OTP Code</label> <input type=""text"" id=""otpCode"" value={otpCode} onChange={(e) => setOtpCode(e.target.value)} placeholder=""123456"" required maxLength={6} // Assuming 6 digits pattern=""\d{6}"" // Basic pattern validation className={`${inputStyle} tracking-widest text-center`} // Style for OTP input disabled={isLoading} /> <button type=""submit"" disabled={isLoading || otpCode.length !== 6} className={`${buttonStyle} w-full`}> {isLoading ? 'Verifying...' : 'Verify Code'} </button> <button type=""button"" onClick={() => { setIsOtpSent(false); setError(null); setMessage(null); setOtpCode(''); setVerifyId(null); }} className=""text-sm text-blue-600 hover:underline mt-4 text-center block w-full disabled:opacity-50"" disabled={isLoading} > Entered wrong number? Start over. </button> </form> )} {isVerified && ( <div className=""text-center""> <p className=""text-xl font-semibold text-green-700"">Verification Complete!</p> <button type=""button"" onClick={() => { setIsVerified(false); setPhoneNumber(''); /* Reset more state if needed */ }} className={`${buttonStyle} mt-6`} > Verify Another Number </button> </div> )} </div> ); }
'use client'
: Necessary because this component usesuseState
and interacts with browser events/APIs.- State Management: Uses
useState
to manage phone number input, OTP code input, loading state, errors, success messages, theverifyId
, and the UI flow (isOtpSent
,isVerified
). handleRequestOtp
: Sends the phone number to/api/auth/request-otp
. On success, it stores the returnedverifyId
in state and updates the UI to show the OTP input form.handleVerifyOtp
: Sends theverifyId
(retrieved from state) and the user-enteredotpCode
to/api/auth/verify-otp
. Updates UI based on success or failure.- UI Flow: Conditionally renders the phone number form, the OTP form, or the success message based on
isOtpSent
andisVerified
states. - Error/Message Display: Shows feedback to the user.
- Security Note (Updated): Added an inline comment warning about storing
verifyId
in React state. The note in Section 4 is also strengthened. This approach is acceptable for a simplified example but not recommended for production. It's vulnerable to issues like losing state on page refresh and potential client-side manipulation. A more robust approach involves managing theverifyId
server-side, typically by storing it in a secure, httpOnly cookie tied to the user's session or in a server-side store (like Redis or a database) linked to a session identifier. The server would then retrieve theverifyId
based on the session when the verification request comes in, rather than relying on the client to send it back.
Security Considerations
- API Key Security: Your
MESSAGEBIRD_API_KEY
is highly sensitive. Keep it in.env.local
and ensure this file is listed in your.gitignore
. Never expose it client-side. Use environment variables in your deployment environment. - Rate Limiting: Implement rate limiting on your API routes (
/api/auth/request-otp
,/api/auth/verify-otp
) to prevent abuse (e.g., flooding users with OTPs, brute-forcing codes). This is often done using middleware in Next.js. Libraries likerate-limiter-flexible
can be integrated, or platform features like Vercel's built-in IP rate limiting can be used. - Input Validation: Rigorously validate all inputs on the server-side (API routes). Check phone number format (E.164), token format (digits, length), and
verifyId
presence/format. verifyId
Handling (Crucial): As highlighted previously, returningverifyId
directly to the client and storing it in frontend state is convenient for demos but insecure for production. An attacker could potentially intercept or manipulate it. Prefer server-side management: Store theverifyId
in a secure, httpOnly cookie (potentially encrypted) or in server-side session storage (e.g., Redis linked to a session ID stored in a secure cookie). When verifying, retrieve theverifyId
on the server based on the user's session, not from the client request body.- Protecting API Routes: Ensure that only authorized users can initiate the 2FA setup or verification process if it's tied to user accounts. Implement authentication checks in your API routes before calling MessageBird.
- Token Timeout: Be aware of the MessageBird token validity period (default 30 seconds). Inform users and handle expired tokens gracefully.
Error Handling and Logging
- API Route Errors: Use
try...catch
blocks in API routes. Log detailed errors server-side (including MessageBird error objects) for debugging. Return clear, user-friendly error messages to the frontend, avoiding exposing sensitive details. - MessageBird Errors: Parse the
err.errors
array provided by the MessageBird SDK callback for specific error codes and descriptions (e.g., invalid phone number, invalid token, expired token). Check thedescription
field for the most accurate information. See examples in API route code. Refer to the official MessageBird API documentation for a list of error codes. - Frontend Errors: Display errors received from the API to the user clearly. Handle network errors during
fetch
calls. - Logging: Implement structured logging (e.g., using Pino or Winston) on the server-side to capture request details, successes, and errors for monitoring and troubleshooting.
Testing
- Manual Testing:
- Run the app (
npm run dev
). - Navigate to
/verify
. - Enter your real phone number in E.164 format. Click ""Send OTP"".
- Check your phone for the SMS.
- Enter the received code. Click ""Verify Code"".
- Test error cases: invalid phone number format, incorrect OTP, expired OTP (wait > 30s), requesting OTP multiple times quickly (test rate limiting if implemented).
- Run the app (
- Unit Testing (API Routes): Use a testing framework like Jest. Mock the
messagebird
SDK to simulate successful and error responses fromverify.create
andverify.verify
without making actual API calls. Test input validation logic. - Integration Testing (Optional): Use tools like Playwright or Cypress to automate browser interactions, simulating the full user flow from entering the phone number to verifying the OTP. This requires careful setup, potentially involving test MessageBird credentials or mocking the API at the network level.
Deployment
- Platform: Deploy your Next.js application to platforms like Vercel, Netlify, AWS Amplify, or a traditional Node.js server.
- Environment Variables: Configure
MESSAGEBIRD_API_KEY
andDATABASE_URL
(if used) as environment variables in your deployment platform's settings. Do not hardcode them. - Build Command: Typically
npm run build
. - Database: Ensure your deployed application can connect to your production database. Configure connection pooling appropriately.
- HTTPS: Always use HTTPS in production. Deployment platforms usually handle this automatically.
Troubleshooting and Caveats
- Invalid API Key: Error messages often indicate authentication failure. Double-check your
MESSAGEBIRD_API_KEY
in.env.local
and your deployment environment variables. Ensure it's a Live key. - Invalid Phone Number: MessageBird expects E.164 format (
+
followed by country code and number, e.g.,+14155552671
). Ensure your frontend sends this format and your backend validation checks for it. Check theerr.errors[0].description
from MessageBird for specifics (error code21
often relates to invalid parameters). - Alphanumeric Sender ID Not Working: Support for alphanumeric
originator
values varies by country (e.g., not supported in the US/Canada). Test with a purchased virtual number from MessageBird as the originator for better reliability, or stick to the default 'Code' / 'MessageBird' if acceptable. - Token Expired / Invalid: The default timeout is 30 seconds. If the user takes longer,
verify.verify
will fail. Handle this error by checking theerr.errors[0].description
(which might indicate an invalid or expired token/ID, sometimes associated with code10
) and prompting the user to request a new code. Ensure the correctverifyId
is being used (especially relevant if using the less secure client-side storage method). Refer to official MessageBird error documentation for definitive code meanings. - Rate Limits: MessageBird enforces rate limits. If you send too many requests too quickly, you'll get errors. Implement client-side and server-side rate limiting (see Security Considerations).
- Message Not Received: Check MessageBird dashboard logs for delivery status. Ensure the number is correct and the phone has service. Test with different carriers if possible. Temporary carrier issues can occur.
verifyId
Handling: Re-iterate the security implications. If theverifyId
is handled insecurely on the client-side, it can be lost (breaking the flow) or potentially compromised. Server-side session storage is strongly recommended for production environments.- Cost: Sending SMS messages incurs costs. Monitor your MessageBird usage.
Complete Code Repository
A repository containing the complete code for this guide should be created and linked here for reference. (Ensure you create a repository and replace this section with the actual link upon completion.)
Conclusion
You have successfully implemented SMS-based OTP verification in your Next.js application using the MessageBird Verify API. This involved setting up the project, creating API routes for requesting and verifying codes, building frontend components for user interaction, and considering crucial aspects like security, error handling, and testing.
This provides a solid foundation for enhancing your application's security with 2FA or implementing reliable phone number verification flows. Remember to adapt the verifyId
handling to use a secure server-side mechanism and integrate database updates according to your specific application requirements and security standards for production deployment. You can further enhance this by adding support for voice OTP (type: 'tts'
) as a fallback or primary method.