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.