code examples
code examples
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.
MessageBird with Next.js and Supabase: OTP-Based Two-Factor Authentication
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:
- 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)
- Supabase account and project setup (Get started free)
- A phone number for testing SMS OTP delivery
Project Overview
Architecture Flow:
- User enters phone number in Next.js frontend
- Frontend calls API route to request OTP
- Next.js API route generates secure 6-digit OTP
- OTP stored in Supabase database with expiration timestamp
- MessageBird sends OTP via SMS to user's phone
- User enters received OTP code
- Frontend calls verification API route
- System validates OTP (correct code, not expired, not used)
- Supabase creates authenticated session
- User gains access to protected resources
Technologies:
- Next.js 14+: React framework with App Router for building API routes and frontend
- MessageBird Node.js SDK: Official SDK for sending SMS messages (Documentation)
- Supabase: PostgreSQL database and authentication backend (Auth Documentation)
- @supabase/ssr: Server-side rendering authentication helpers for Next.js
- TypeScript: For type safety and better developer experience (recommended)
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
npm install messagebird @supabase/supabase-js @supabase/ssr
npm install -D @types/nodePackage 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.js
Configure Environment Variables
Create .env.local in project root:
# MessageBird Configuration
MESSAGEBIRD_ACCESS_KEY=YOUR_ACCESS_KEY_HERE
# Supabase Configuration
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_ANON_KEY_HERE
SUPABASE_SERVICE_ROLE_KEY=YOUR_SERVICE_ROLE_KEY_HERE
# OTP Configuration
OTP_EXPIRATION_MINUTES=5
OTP_LENGTH=6
MAX_OTP_ATTEMPTS=3
RATE_LIMIT_WINDOW_MINUTES=15
MAX_REQUESTS_PER_WINDOW=3Get your credentials:
- MessageBird Access Key from MessageBird Dashboard under Developers > Access Keys
- Supabase credentials from Supabase Dashboard > Project Settings > API
- Never commit
.env.localto version control
Update .gitignore
Ensure your .gitignore includes:
# Next.js
.next/
out/
# Environment Variables
.env*.local
# Dependencies
node_modules/
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:
- Row Level Security (RLS) prevents unauthorized access (Supabase RLS Documentation)
- 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:
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:
- 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:
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-secure random: Use
crypto.randomInt(), notMath.random()(predictable) - OTP Length: 6 digits balances security and usability (MojoAuth Best Practices)
- Expiration: 5-10 minutes prevents extended attack windows
- Timing Attack Prevention: Constant-time comparison prevents timing-based OTP guessing
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
npm run devNavigate to http://localhost:3000
Test Flow
-
Request OTP:
- Enter phone number in E.164 format:
+15551234567 - Click "Send OTP"
- Check phone for SMS message
- Enter phone number in E.164 format:
-
Verify OTP:
- Enter 6-digit code from SMS
- Click "Verify OTP"
- View success message
Common Test Cases
| Test Case | Expected Behavior |
|---|---|
| Valid phone + correct OTP | ✓ Success, marked verified |
| Valid phone + wrong OTP | ✗ Error, attempts incremented |
| Expired OTP | ✗ Error, must request new OTP |
| 3+ failed attempts | ✗ Error, must request new OTP |
| Rate limit exceeded | ✗ Error, wait 15 minutes |
| Invalid phone format | ✗ Error, must use E.164 |
| Reusing verified OTP | ✗ Error, OTP already used |
Testing with MessageBird Trial
- Trial Account: MessageBird trial accounts may have sending restrictions (MessageBird Pricing)
- Test Credits: New accounts receive test credits for development
- Verification: Some regions require sender ID verification
10. SMS OTP Security Best Practices and Vulnerabilities
Critical Security Measures Implemented
-
Crypto-Secure OTP Generation
- Uses
crypto.randomInt()instead ofMath.random() - 6-digit codes provide 1,000,000 combinations
- Uses
-
Timing Attack Prevention
- Constant-time comparison in
secureCompare() - Prevents attackers from guessing OTP via response timing
- Constant-time comparison in
-
Brute Force Protection
- Maximum 3 verification attempts per OTP
- After 3 failed attempts, OTP invalidated
-
Rate Limiting
- Max 3 OTP requests per 15 minutes per phone number
- Prevents OTP spam and DoS attacks
- Production: Use Redis for distributed rate limiting
-
OTP Expiration
- 5-minute expiration window
- Reduces attack surface time
-
OTP Reuse Prevention
verifiedflag prevents code reuse- Once verified, OTP cannot be used again
-
Database Security
- Row Level Security (RLS) enabled
- Service role required for API operations
- Phone number format validation at DB level
-
Environment Variable Security
- Sensitive keys in
.env.local(never committed) - Service role key only used server-side
- Client-only gets public anon key
- Sensitive keys in
Additional Recommendations
-
HTTPS Only in Production
- Never send OTPs over unencrypted connections
- Use Vercel/Netlify for automatic HTTPS
-
Audit Logging
- Log all OTP requests and verification attempts
- Monitor for suspicious patterns
- Store logs in separate secure location
-
Input Sanitization
- Validate phone number format (E.164)
- Validate OTP format (6 digits)
- Reject invalid inputs before processing
-
SMS Message Security
- Clear warning not to share code
- Include expiration time in message
- Brand name in originator for phishing prevention
Sources for Security Practices
- NIST Multi-Factor Authentication Guidelines (2024)
- MojoAuth OTP Best Practices
- 1Password: Secure Authentication Methods (2024)
11. Error Handling for MessageBird and Supabase
MessageBird Error Codes
| Error | Cause | Solution |
|---|---|---|
| Error 2 | Invalid access key | Verify MESSAGEBIRD_ACCESS_KEY in .env.local |
| Error 9 | Insufficient balance | Add credits to MessageBird account |
| Error 21 | Invalid originator | Use valid phone number or alphanumeric (≤11 chars) |
| Error 25 | Invalid recipient | Ensure phone number in E.164 format |
MessageBird 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:
bashnpm install -g vercel -
Deploy:
bashvercel -
Configure Environment Variables:
- Go to Vercel Dashboard > Project > Settings > Environment Variables
- Add all variables from
.env.local - Ensure
SUPABASE_SERVICE_ROLE_KEYis only accessible server-side
-
Production Considerations:
- Use Redis for distributed rate limiting (Vercel KV or Upstash)
- Enable Vercel Analytics for monitoring
- Set up custom domain with HTTPS
- Configure CORS if needed for API routes
Alternative Platforms
- Netlify: Similar to Vercel, great Next.js support
- Railway: Easy Docker deployment
- AWS Amplify: AWS-native hosting
- Self-hosted: Docker + Nginx reverse proxy
Environment Variable Configuration
Ensure all required variables are set in production:
MESSAGEBIRD_ACCESS_KEY=live_api_key
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_production_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_production_service_role_key
OTP_EXPIRATION_MINUTES=5
OTP_LENGTH=6
MAX_OTP_ATTEMPTS=3Post-Deployment Checklist
- All environment variables configured
- HTTPS enabled (automatic on Vercel/Netlify)
- MessageBird account has sufficient credits
- Supabase RLS policies tested
- Rate limiting verified (use Redis in production)
- Error logging configured (Sentry, LogRocket, etc.)
- Test OTP flow end-to-end in production
- Monitor for failed SMS deliveries
- Set up alerts for high error rates
13. Troubleshooting Common OTP Issues
SMS OTP Not Received
Possible Causes:
- Invalid phone number format (must be E.164 international format)
- MessageBird account has insufficient SMS credits
- Mobile carrier blocking or filtering SMS OTP messages
- MessageBird sender ID (originator) not approved for region
Solutions:
- Verify phone format:
+[country code][number] - Check MessageBird dashboard for delivery status
- Review MessageBird balance
- Test with different carrier/phone number
"Invalid Access Key" Error
Cause: MessageBird access key incorrect or not set
Solution:
- Verify key in MessageBird Dashboard > Developers > Access Keys
- Ensure
MESSAGEBIRD_ACCESS_KEYin.env.localmatches - Restart Next.js dev server after changing
.env.local - Check for extra spaces or quotes in environment variable
Supabase Connection Errors
Possible Causes:
- Incorrect Supabase URL or keys
- RLS policies blocking access
- Service role key not set for server operations
Solutions:
- Verify credentials in Supabase Dashboard > Settings > API
- Check RLS policies allow service role access
- Ensure using
supabaseAdmin(service role) in API routes - Test connection with Supabase SQL Editor
Rate Limit Not Working
Cause: In-memory rate limiting doesn't work across serverless instances
Solution (Production):
Use Redis for distributed rate limiting:
npm install @upstash/redisimport { 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:
- Ensure Supabase stores timestamps with timezone (
TIMESTAMP WITH TIME ZONE) - Compare using:
new Date(expires_at) < new Date() - Verify server and database use UTC
14. Advanced OTP Features and Enhancements
Recommended Improvements
-
Email OTP Alternative
- Add email as fallback delivery method
- Use Supabase Auth email OTP: Supabase Email OTP
-
Resend OTP Functionality
- Add "Resend Code" button after 60 seconds
- Track resend attempts separately from verification attempts
-
Multi-Language Support
- Localize SMS messages based on phone country code
- Use i18n for frontend messages
-
OTP Analytics Dashboard
- Track delivery rates, failure reasons
- Monitor suspicious patterns
- Visualize usage metrics
-
Backup Authentication Methods
- Authenticator app (TOTP) as alternative
- WebAuthn/passkeys for passwordless
- Email magic links
-
Advanced Rate Limiting
- Progressive delays (exponential backoff)
- IP-based rate limiting in addition to phone
- CAPTCHA after repeated failures
-
Improved UX
- Auto-advance on 6-digit entry
- Countdown timer for expiration
- Auto-fill OTP from SMS (Web OTP API)
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 #123456
15. MessageBird and Supabase Resources
Official Documentation
- MessageBird Node.js SDK: GitHub
- MessageBird SMS API: API Reference
- MessageBird Tutorials: Send SMS with Node.js
- Supabase Auth: Next.js Guide
- Supabase Database: PostgreSQL Documentation
- Next.js API Routes: Documentation
Security Resources
- NIST Multi-Factor Authentication: Guidelines (2024)
- OTP Best Practices: MojoAuth Guide
- 1Password Authentication Methods: Security Analysis (2024)
- Supabase Row Level Security: RLS Guide
Community and Support
- MessageBird Support: support@messagebird.com
- Supabase Discord: Join Community
- Next.js Discussions: GitHub Discussions
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:
- 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
When to use rate limiting in Express SMS API
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.
How to send SMS with Node.js and Express
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.
What is the Vonage Messages API used for
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.
Why does Vonage require a private key
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.
When should I whitelist a destination number in Vonage
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.
Can I use the Vonage SMS API with Node.js
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.
How to set up Vonage Messages API with Express
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.
What is the role of dotenv in a Node.js SMS project
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.
Why is setting the default SMS API to Messages API important
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.
How to handle errors when sending SMS with Vonage
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.
What is E.164 number format and why is it important
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.
How to secure my Vonage API credentials
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.
What are best practices for deploying a Node.js SMS app
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.
How to troubleshoot Vonage SMS API authentication errors
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'.