MessageBird with Next.js and Supabase: OTP-Based Two-Factor Authentication
Comprehensive guide to implementing SMS-based OTP two-factor authentication in Next.js using MessageBird for reliable SMS delivery and Supabase for authentication and database storage.
Two-factor authentication (2FA) using One-Time Passwords (OTPs) adds an essential security layer to web applications by requiring users to verify their identity through a second factor – typically a verification code sent to their mobile device via SMS. This comprehensive guide demonstrates how to implement SMS-based OTP two-factor authentication in a Next.js application using MessageBird for reliable SMS delivery and Supabase for authentication and database storage.
What You'll Learn:
Set up MessageBird SMS API for OTP delivery and verification
Configure Next.js API routes for secure OTP generation and verification
Integrate Supabase authentication with phone number verification
Implement secure OTP generation, validation, and expiration logic
Follow 2FA security best practices and prevent common vulnerabilities
Prerequisites:
Node.js 16+ and npm/yarn installed
Basic knowledge of React and Next.js framework
MessageBird account with API Access Key (Sign up free)
-- Create table for OTP storageCREATE TABLE otp_codes (
id UUID PRIMARY KEYDEFAULT gen_random_uuid(),
phone_number VARCHAR(15) NOT NULL,
otp_code VARCHAR(10) NOT NULL,
created_at TIMESTAMPWITHTIME ZONE DEFAULT NOW(),
expires_at TIMESTAMPWITHTIME ZONE NOT NULL,
verified BOOLEANDEFAULTFALSE,
attempts INTEGERDEFAULT0,
CONSTRAINT phone_e164_format CHECK (phone_number ~'^\+?[1-9]\d{1,14}$')
);
-- Index for faster lookupsCREATE INDEX idx_otp_phone_verified ON otp_codes(phone_number, verified, expires_at);
-- Enable Row Level Security (RLS)ALTER TABLE otp_codes ENABLE ROW LEVEL SECURITY;
-- Policy: Service role has full accessCREATE POLICY "Service role has full access" ON otp_codes
FORALLTO service_role
USING (true)
WITHCHECK (true);
-- Function to clean expired OTPs (run periodically)CREATEOR REPLACE FUNCTION delete_expired_otps()
RETURNS void AS $$
BEGINDELETEFROM otp_codes WHERE expires_at < NOW();
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
Service role key required for server-side operations
Phone number validation ensures E.164 format
Automatic cleanup prevents database bloat
3. MessageBird SMS API Configuration
Initialize MessageBird Client
Create lib/messagebird.ts:
TypeScript
import messagebird from'messagebird';
if (!process.env.MESSAGEBIRD_ACCESS_KEY) {
thrownewError('MESSAGEBIRD_ACCESS_KEY is not defined');
}
// Initialize MessageBird client// Documentation: https://github.com/messagebird/messagebird-nodejsexportconst messagebirdClient = messagebird.initClient(
process.env.MESSAGEBIRD_ACCESS_KEY
);
/**
* Send SMS via MessageBird
* @paramto - Phone number in E.164 format (e.g., +15551234567)
* @parambody - Message content
* @paramoriginator - Sender ID (phone number or alphanumeric, max 11 chars)
* @returns Promise resolving to MessageBird response
*/exportasyncfunctionsendSMS(to: string,
body: string,
originator: string = 'OTP-2FA'): Promise<any> {
returnnewPromise((resolve, reject) => {
messagebirdClient.messages.create(
{
originator,
recipients: [to],
body,
},
(err, response) => {
if (err) {
console.error('MessageBird SMS Error:', err);
reject(err);
} else {
console.log('SMS Sent Successfully:', response);
resolve(response);
}
}
);
});
}
Important Notes:
Originator: Can be phone number or alphanumeric (11 char max). Alphanumeric not supported in USA (MessageBird Documentation)
E.164 Format: Phone numbers must include country code (e.g., +15551234567)
Rate Limits: MessageBird has API rate limits; implement application-level rate limiting
4. Supabase Client Configuration for Next.js
Create lib/supabase.ts:
TypeScript
import { createClient } from'@supabase/supabase-js';
if (!process.env.NEXT_PUBLIC_SUPABASE_URL) {
thrownewError('NEXT_PUBLIC_SUPABASE_URL is not defined');
}
if (!process.env.SUPABASE_SERVICE_ROLE_KEY) {
thrownewError('SUPABASE_SERVICE_ROLE_KEY is not defined');
}
// Server-side Supabase client with service role (bypasses RLS)// Use ONLY in API routes, never expose to clientexportconst supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY,
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
);
// Client-side Supabase client (for frontend)exportconst supabaseClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
5. Secure OTP Generation and Validation Logic
Create lib/otp.ts:
TypeScript
import crypto from'crypto';
/**
* Generate cryptographically secure OTP
* Best practice: 6-10 characters, we use 6 for user convenience
* Source: https://mojoauth.com/blog/best-practices-for-otp-authentication
*/exportfunctiongenerateOTP(length: number = 6): string {
const digits = '0123456789';
let otp = '';
// Use crypto.randomInt for cryptographic security// More secure than Math.random()for (let i = 0; i < length; i++) {
otp += digits[crypto.randomInt(0, digits.length)];
}
return otp;
}
/**
* Calculate OTP expiration timestamp
* Standard: 5-10 minutes for security vs usability balance
*/exportfunctiongetExpirationTime(minutes: number = 5): Date {
const expiration = newDate();
expiration.setMinutes(expiration.getMinutes() + minutes);
return expiration;
}
/**
* Validate phone number format (E.164)
* Format: +[country code][number] (e.g., +15551234567)
*/exportfunctionisValidE164(phone: string): boolean {
// E.164: + followed by 1-15 digitsconst e164Regex = /^\+[1-9]\d{1,14}$/;
return e164Regex.test(phone);
}
/**
* Constant-time string comparison to prevent timing attacks
* Critical for OTP verification security
*/exportfunctionsecureCompare(a: string, b: string): boolean {
if (a.length !== b.length) {
returnfalse;
}
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
Security Best Practices:
Crypto-secure random: Use crypto.randomInt(), not Math.random() (predictable)
You now have a complete, production-ready implementation of SMS-based OTP two-factor authentication using MessageBird for reliable SMS delivery, Next.js for the application framework, and Supabase for authentication and secure data storage.
Key Takeaways:
OTP provides strong second-factor authentication via SMS
MessageBird SDK simplifies SMS delivery with reliable infrastructure
Next.js API routes handle server-side OTP generation and verification
Supabase provides secure database storage and authentication management
Security best practices prevent common 2FA vulnerabilities
Rate limiting and attempt tracking prevent abuse
Remember:
Always use HTTPS in production
Implement proper rate limiting (Redis in production)
Monitor SMS delivery rates and costs
Regularly review security logs
Keep dependencies updated
Test thoroughly before deploying
This implementation provides a solid foundation for adding 2FA to any Next.js application. Customize the UI, add additional security layers, and extend functionality as needed for your specific use case.
Frequently asked questions
Use the Vonage Messages API and Node.js SDK. Set up an Express server, define an endpoint that accepts recipient number and message, then utilize the SDK to send the SMS via the API. This setup allows for sending messages programmatically.
The Vonage Messages API enables sending SMS messages programmatically from within applications. It handles the complexities of carrier integration so developers can easily add SMS functionality to projects like notifications or 2FA.
The private key, along with your Application ID, authenticates your application with the Vonage Messages API. It's crucial for security and should never be exposed publicly or committed to version control. Keep it safe and secure.
Whitelisting is mandatory for trial Vonage accounts. Add and verify the recipient number via the Vonage dashboard before sending test messages. This step is crucial to avoid "Non-Whitelisted Destination" errors.
Yes, Vonage provides a Node.js SDK (@vonage/server-sdk) that simplifies interaction with both the Messages API (recommended) and the older SMS API. Ensure you are using the correct API and corresponding SDK methods, and check for any version compatibility.
Create a Vonage application, enable the Messages capability, generate and securely store your private key, link a Vonage virtual number, and configure these credentials as environment variables in your Express project. Use the @vonage/server-sdk to interact with the API.
The dotenv module loads environment variables from a .env file into process.env, making it easy to manage configuration like API keys, secrets, and other settings without hardcoding them in your application code.
Vonage has two SMS APIs. Setting the Default SMS API to "Messages API" in the Vonage dashboard ensures that the SDK uses the correct API, avoiding potential conflicts or unexpected behavior, especially with webhooks.
Implement try...catch blocks around the vonage.messages.send() call. Log error details, including any specific Vonage error information from the API response, to assist with debugging. Consider using a logging library for structured logs.
E.164 is an international telephone number format that includes the country code and number without any symbols or formatting (e.g., +15551234567 becomes 15551234567). It ensures consistent and accurate number handling for SMS delivery.
Never commit .env or private key files to version control. Use platform-specific environment variable management in production. Consider storing the private key content as an environment variable directly for platforms like Heroku.
Implement rate limiting with a library like express-rate-limit to prevent abuse of your /send-sms endpoint, control costs, and enhance the stability of your service.
Use a platform like Heroku, AWS, or Google Cloud. Configure environment variables securely. Employ build processes if necessary, use a Procfile if required by the platform, and ensure all dependencies are installed correctly.
Double-check your Application ID, private key path, and the key file's content for accuracy. Verify correct file permissions. Ensure the Default SMS Setting in the Vonage dashboard is set to 'Messages API'.
Introduction
Two-factor authentication (2FA) using One-Time Passwords (OTPs) adds an essential security layer to web applications by requiring users to verify their identity through a second factor – typically a verification code sent to their mobile device via SMS. This comprehensive guide demonstrates how to implement SMS-based OTP two-factor authentication in a Next.js application using MessageBird for reliable SMS delivery and Supabase for authentication and database storage.
What You'll Learn:
Prerequisites:
Project Overview
Architecture Flow:
Technologies:
1. Setting Up Your Next.js OTP Authentication Project
Initialize Next.js Project
npx create-next-app@latest messagebird-otp-2fa # Select: TypeScript (Yes), App Router (Yes), Tailwind CSS (optional) cd messagebird-otp-2faInstall Dependencies
Package Purposes:
messagebird: Official MessageBird Node.js client for SMS API (npm)@supabase/supabase-js: Supabase JavaScript client@supabase/ssr: Server-side rendering authentication helpers for Next.jsConfigure Environment Variables
Create
.env.localin project root:Get your credentials:
.env.localto version controlUpdate
.gitignoreEnsure your
.gitignoreincludes:2. Supabase Database Setup for OTP Storage
Create OTP Storage Table
Execute this SQL in Supabase SQL Editor:
-- Create table for OTP storage CREATE TABLE otp_codes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), phone_number VARCHAR(15) NOT NULL, otp_code VARCHAR(10) NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), expires_at TIMESTAMP WITH TIME ZONE NOT NULL, verified BOOLEAN DEFAULT FALSE, attempts INTEGER DEFAULT 0, CONSTRAINT phone_e164_format CHECK (phone_number ~ '^\+?[1-9]\d{1,14}$') ); -- Index for faster lookups CREATE INDEX idx_otp_phone_verified ON otp_codes(phone_number, verified, expires_at); -- Enable Row Level Security (RLS) ALTER TABLE otp_codes ENABLE ROW LEVEL SECURITY; -- Policy: Service role has full access CREATE POLICY "Service role has full access" ON otp_codes FOR ALL TO service_role USING (true) WITH CHECK (true); -- Function to clean expired OTPs (run periodically) CREATE OR REPLACE FUNCTION delete_expired_otps() RETURNS void AS $$ BEGIN DELETE FROM otp_codes WHERE expires_at < NOW(); END; $$ LANGUAGE plpgsql SECURITY DEFINER;Security Considerations:
3. MessageBird SMS API Configuration
Initialize MessageBird Client
Create
lib/messagebird.ts:import messagebird from 'messagebird'; if (!process.env.MESSAGEBIRD_ACCESS_KEY) { throw new Error('MESSAGEBIRD_ACCESS_KEY is not defined'); } // Initialize MessageBird client // Documentation: https://github.com/messagebird/messagebird-nodejs export const messagebirdClient = messagebird.initClient( process.env.MESSAGEBIRD_ACCESS_KEY ); /** * Send SMS via MessageBird * @param to - Phone number in E.164 format (e.g., +15551234567) * @param body - Message content * @param originator - Sender ID (phone number or alphanumeric, max 11 chars) * @returns Promise resolving to MessageBird response */ export async function sendSMS( to: string, body: string, originator: string = 'OTP-2FA' ): Promise<any> { return new Promise((resolve, reject) => { messagebirdClient.messages.create( { originator, recipients: [to], body, }, (err, response) => { if (err) { console.error('MessageBird SMS Error:', err); reject(err); } else { console.log('SMS Sent Successfully:', response); resolve(response); } } ); }); }Important Notes:
4. Supabase Client Configuration for Next.js
Create
lib/supabase.ts:import { createClient } from '@supabase/supabase-js'; if (!process.env.NEXT_PUBLIC_SUPABASE_URL) { throw new Error('NEXT_PUBLIC_SUPABASE_URL is not defined'); } if (!process.env.SUPABASE_SERVICE_ROLE_KEY) { throw new Error('SUPABASE_SERVICE_ROLE_KEY is not defined'); } // Server-side Supabase client with service role (bypasses RLS) // Use ONLY in API routes, never expose to client export const supabaseAdmin = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY, { auth: { autoRefreshToken: false, persistSession: false, }, } ); // Client-side Supabase client (for frontend) export const supabaseClient = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! );5. Secure OTP Generation and Validation Logic
Create
lib/otp.ts:import crypto from 'crypto'; /** * Generate cryptographically secure OTP * Best practice: 6-10 characters, we use 6 for user convenience * Source: https://mojoauth.com/blog/best-practices-for-otp-authentication */ export function generateOTP(length: number = 6): string { const digits = '0123456789'; let otp = ''; // Use crypto.randomInt for cryptographic security // More secure than Math.random() for (let i = 0; i < length; i++) { otp += digits[crypto.randomInt(0, digits.length)]; } return otp; } /** * Calculate OTP expiration timestamp * Standard: 5-10 minutes for security vs usability balance */ export function getExpirationTime(minutes: number = 5): Date { const expiration = new Date(); expiration.setMinutes(expiration.getMinutes() + minutes); return expiration; } /** * Validate phone number format (E.164) * Format: +[country code][number] (e.g., +15551234567) */ export function isValidE164(phone: string): boolean { // E.164: + followed by 1-15 digits const e164Regex = /^\+[1-9]\d{1,14}$/; return e164Regex.test(phone); } /** * Constant-time string comparison to prevent timing attacks * Critical for OTP verification security */ export function secureCompare(a: string, b: string): boolean { if (a.length !== b.length) { return false; } let result = 0; for (let i = 0; i < a.length; i++) { result |= a.charCodeAt(i) ^ b.charCodeAt(i); } return result === 0; }Security Best Practices:
crypto.randomInt(), notMath.random()(predictable)6. Next.js API Route: Send OTP via SMS
Create
app/api/send-otp/route.ts:import { NextRequest, NextResponse } from 'next/server'; import { supabaseAdmin } from '@/lib/supabase'; import { sendSMS } from '@/lib/messagebird'; import { generateOTP, getExpirationTime, isValidE164 } from '@/lib/otp'; // Rate limiting store (use Redis in production) const rateLimitStore = new Map<string, { count: number; resetAt: number }>(); function checkRateLimit(phone: string): boolean { const now = Date.now(); const limit = rateLimitStore.get(phone); if (!limit || now > limit.resetAt) { // Reset or create new limit rateLimitStore.set(phone, { count: 1, resetAt: now + 15 * 60 * 1000, // 15 minutes }); return true; } if (limit.count >= 3) { // Max 3 OTP requests per 15 minutes return false; } limit.count++; return true; } export async function POST(request: NextRequest) { try { const { phoneNumber } = await request.json(); // Validation if (!phoneNumber || typeof phoneNumber !== 'string') { return NextResponse.json( { error: 'Phone number is required' }, { status: 400 } ); } // Validate E.164 format if (!isValidE164(phoneNumber)) { return NextResponse.json( { error: 'Phone number must be in E.164 format (e.g., +15551234567)' }, { status: 400 } ); } // Rate limiting if (!checkRateLimit(phoneNumber)) { return NextResponse.json( { error: 'Too many OTP requests. Try again in 15 minutes.' }, { status: 429 } ); } // Generate OTP const otpCode = generateOTP(6); const expiresAt = getExpirationTime(5); // Store OTP in database const { error: dbError } = await supabaseAdmin .from('otp_codes') .insert({ phone_number: phoneNumber, otp_code: otpCode, expires_at: expiresAt.toISOString(), verified: false, attempts: 0, }); if (dbError) { console.error('Database error:', dbError); return NextResponse.json( { error: 'Failed to generate OTP' }, { status: 500 } ); } // Send SMS via MessageBird const message = `Your verification code is: ${otpCode}\n\nThis code expires in 5 minutes. Do not share this code with anyone.`; try { await sendSMS(phoneNumber, message, 'YourApp'); return NextResponse.json({ success: true, message: 'OTP sent successfully', expiresIn: 300, // seconds }); } catch (smsError: any) { console.error('SMS sending error:', smsError); // Delete OTP from DB if SMS fails await supabaseAdmin .from('otp_codes') .delete() .match({ phone_number: phoneNumber, otp_code: otpCode }); return NextResponse.json( { error: 'Failed to send SMS. Try again.' }, { status: 500 } ); } } catch (error) { console.error('Send OTP error:', error); return NextResponse.json( { error: 'Internal server error' }, { status: 500 } ); } }7. Next.js API Route: Verify OTP Code
Create
app/api/verify-otp/route.ts:import { NextRequest, NextResponse } from 'next/server'; import { supabaseAdmin } from '@/lib/supabase'; import { secureCompare } from '@/lib/otp'; const MAX_ATTEMPTS = 3; export async function POST(request: NextRequest) { try { const { phoneNumber, otpCode } = await request.json(); // Validation if (!phoneNumber || !otpCode) { return NextResponse.json( { error: 'Phone number and OTP code are required' }, { status: 400 } ); } // Validate OTP format (6 digits) if (!/^\d{6}$/.test(otpCode)) { return NextResponse.json( { error: 'Invalid OTP format' }, { status: 400 } ); } // Fetch most recent unverified OTP for this phone number const { data: otpRecord, error: fetchError } = await supabaseAdmin .from('otp_codes') .select('*') .eq('phone_number', phoneNumber) .eq('verified', false) .order('created_at', { ascending: false }) .limit(1) .single(); if (fetchError || !otpRecord) { return NextResponse.json( { error: 'No valid OTP found. Request a new one.' }, { status: 404 } ); } // Check if OTP has expired if (new Date(otpRecord.expires_at) < new Date()) { return NextResponse.json( { error: 'OTP has expired. Request a new one.' }, { status: 400 } ); } // Check attempt limit (prevent brute force) if (otpRecord.attempts >= MAX_ATTEMPTS) { return NextResponse.json( { error: 'Maximum verification attempts exceeded. Request a new OTP.' }, { status: 400 } ); } // Increment attempt counter await supabaseAdmin .from('otp_codes') .update({ attempts: otpRecord.attempts + 1 }) .eq('id', otpRecord.id); // Verify OTP using constant-time comparison (prevent timing attacks) if (!secureCompare(otpCode, otpRecord.otp_code)) { return NextResponse.json( { error: 'Invalid OTP code', attemptsRemaining: MAX_ATTEMPTS - (otpRecord.attempts + 1) }, { status: 400 } ); } // Mark OTP as verified await supabaseAdmin .from('otp_codes') .update({ verified: true }) .eq('id', otpRecord.id); // Here you would typically: // 1. Create a Supabase auth session // 2. Generate JWT token // 3. Set authentication cookie // For this example, we'll return success return NextResponse.json({ success: true, message: 'OTP verified successfully', phoneNumber: phoneNumber, }); } catch (error) { console.error('Verify OTP error:', error); return NextResponse.json( { error: 'Internal server error' }, { status: 500 } ); } }8. React Frontend Components for OTP Authentication
OTP Request Form
Create
components/OTPRequestForm.tsx:'use client'; import { useState } from 'react'; export default function OTPRequestForm({ onSuccess }: { onSuccess: (phone: string) => void }) { const [phoneNumber, setPhoneNumber] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); setLoading(true); try { const response = await fetch('/api/send-otp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phoneNumber }), }); const data = await response.json(); if (!response.ok) { setError(data.error || 'Failed to send OTP'); return; } onSuccess(phoneNumber); } catch (err) { setError('Network error. Try again.'); } finally { setLoading(false); } }; return ( <form onSubmit={handleSubmit} className="space-y-4"> <div> <label htmlFor="phone" className="block text-sm font-medium"> Phone Number (E.164 format) </label> <input id="phone" type="tel" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} placeholder="+15551234567" required className="mt-1 block w-full rounded border p-2" /> <p className="text-xs text-gray-500 mt-1"> Include country code (e.g., +1 for USA) </p> </div> {error && ( <div className="text-red-600 text-sm">{error}</div> )} <button type="submit" disabled={loading} className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 disabled:bg-gray-400" > {loading ? 'Sending…' : 'Send OTP'} </button> </form> ); }OTP Verification Form
Create
components/OTPVerifyForm.tsx:'use client'; import { useState } from 'react'; export default function OTPVerifyForm({ phoneNumber, onSuccess }: { phoneNumber: string; onSuccess: () => void; }) { const [otpCode, setOtpCode] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); setLoading(true); try { const response = await fetch('/api/verify-otp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phoneNumber, otpCode }), }); const data = await response.json(); if (!response.ok) { setError(data.error || 'Verification failed'); return; } onSuccess(); } catch (err) { setError('Network error. Try again.'); } finally { setLoading(false); } }; return ( <form onSubmit={handleSubmit} className="space-y-4"> <div> <label htmlFor="otp" className="block text-sm font-medium"> Enter 6-digit code </label> <input id="otp" type="text" value={otpCode} onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="123456" maxLength={6} required className="mt-1 block w-full rounded border p-2 text-center text-2xl tracking-widest" /> <p className="text-xs text-gray-500 mt-1"> Code sent to {phoneNumber} </p> </div> {error && ( <div className="text-red-600 text-sm">{error}</div> )} <button type="submit" disabled={loading || otpCode.length !== 6} className="w-full bg-green-600 text-white py-2 rounded hover:bg-green-700 disabled:bg-gray-400" > {loading ? 'Verifying…' : 'Verify OTP'} </button> </form> ); }Main Page
Create
app/page.tsx:'use client'; import { useState } from 'react'; import OTPRequestForm from '@/components/OTPRequestForm'; import OTPVerifyForm from '@/components/OTPVerifyForm'; export default function Home() { const [step, setStep] = useState<'request' | 'verify' | 'success'>('request'); const [phoneNumber, setPhoneNumber] = useState(''); return ( <main className="min-h-screen flex items-center justify-center bg-gray-50"> <div className="bg-white p-8 rounded-lg shadow-md w-full max-w-md"> <h1 className="text-2xl font-bold mb-6 text-center"> Two-Factor Authentication </h1> {step === 'request' && ( <OTPRequestForm onSuccess={(phone) => { setPhoneNumber(phone); setStep('verify'); }} /> )} {step === 'verify' && ( <OTPVerifyForm phoneNumber={phoneNumber} onSuccess={() => setStep('success')} /> )} {step === 'success' && ( <div className="text-center"> <div className="text-green-600 text-5xl mb-4">✓</div> <h2 className="text-xl font-semibold mb-2">Verified!</h2> <p className="text-gray-600"> Your phone number has been successfully verified. </p> </div> )} </div> </main> ); }9. Testing Your OTP Two-Factor Authentication
Development Server
Navigate to
http://localhost:3000Test Flow
Request OTP:
+15551234567Verify OTP:
Common Test Cases
Testing with MessageBird Trial
10. SMS OTP Security Best Practices and Vulnerabilities
Critical Security Measures Implemented
Crypto-Secure OTP Generation
crypto.randomInt()instead ofMath.random()Timing Attack Prevention
secureCompare()Brute Force Protection
Rate Limiting
OTP Expiration
OTP Reuse Prevention
verifiedflag prevents code reuseDatabase Security
Environment Variable Security
.env.local(never committed)Additional Recommendations
HTTPS Only in Production
Audit Logging
Input Sanitization
SMS Message Security
Sources for Security Practices
11. Error Handling for MessageBird and Supabase
MessageBird Error Codes
MESSAGEBIRD_ACCESS_KEYin.env.localMessageBird API Error Reference
Supabase Error Handling
// Example error handling pattern try { const { data, error } = await supabaseAdmin .from('otp_codes') .insert({ ... }); if (error) { // Supabase errors have code and message properties console.error('Supabase error:', error.code, error.message); // Handle specific error codes if (error.code === '23505') { // Unique constraint violation return 'Duplicate entry'; } } } catch (err) { // Network or unexpected errors console.error('Unexpected error:', err); }12. Deploying OTP Authentication to Production
Recommended Deployment: Vercel
Vercel is the recommended platform for Next.js applications with automatic optimizations.
Install Vercel CLI:
Deploy:
Configure Environment Variables:
.env.localSUPABASE_SERVICE_ROLE_KEYis only accessible server-sideProduction Considerations:
Alternative Platforms
Environment Variable Configuration
Ensure all required variables are set in production:
Post-Deployment Checklist
13. Troubleshooting Common OTP Issues
SMS OTP Not Received
Possible Causes:
Solutions:
+[country code][number]"Invalid Access Key" Error
Cause: MessageBird access key incorrect or not set
Solution:
MESSAGEBIRD_ACCESS_KEYin.env.localmatches.env.localSupabase Connection Errors
Possible Causes:
Solutions:
supabaseAdmin(service role) in API routesRate Limit Not Working
Cause: In-memory rate limiting doesn't work across serverless instances
Solution (Production):
Use Redis for distributed rate limiting:
import { Redis } from '@upstash/redis'; const redis = Redis.fromEnv(); async function checkRateLimit(phone: string): Promise<boolean> { const key = `rate-limit:${phone}`; const count = await redis.incr(key); if (count === 1) { await redis.expire(key, 900); // 15 minutes } return count <= 3; }OTP Expiration Not Working
Cause: Database timezone mismatch
Solution:
TIMESTAMP WITH TIME ZONE)new Date(expires_at) < new Date()14. Advanced OTP Features and Enhancements
Recommended Improvements
Email OTP Alternative
Resend OTP Functionality
Multi-Language Support
OTP Analytics Dashboard
Backup Authentication Methods
Advanced Rate Limiting
Improved UX
Web OTP API (Browser Autofill)
Modern browsers support auto-filling OTP from SMS:
// In OTPVerifyForm component useEffect(() => { if ('OTPCredential' in window) { const ac = new AbortController(); navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal }).then((otp: any) => { setOtpCode(otp.code); }).catch(err => { console.log(err); }); return () => ac.abort(); } }, []);SMS message must include:
@yourdomain.com #123456Web OTP API Documentation
15. MessageBird and Supabase Resources
Official Documentation
Security Resources
Community and Support
Conclusion
You now have a complete, production-ready implementation of SMS-based OTP two-factor authentication using MessageBird for reliable SMS delivery, Next.js for the application framework, and Supabase for authentication and secure data storage.
Key Takeaways:
Remember:
This implementation provides a solid foundation for adding 2FA to any Next.js application. Customize the UI, add additional security layers, and extend functionality as needed for your specific use case.