code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

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:

  1. User enters phone number in Next.js frontend
  2. Frontend calls API route to request OTP
  3. Next.js API route generates secure 6-digit OTP
  4. OTP stored in Supabase database with expiration timestamp
  5. MessageBird sends OTP via SMS to user's phone
  6. User enters received OTP code
  7. Frontend calls verification API route
  8. System validates OTP (correct code, not expired, not used)
  9. Supabase creates authenticated session
  10. 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

bash
npx create-next-app@latest messagebird-otp-2fa
# Select: TypeScript (Yes), App Router (Yes), Tailwind CSS (optional)
cd messagebird-otp-2fa

Install Dependencies

bash
npm install messagebird @supabase/supabase-js @supabase/ssr
npm install -D @types/node

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.js

Configure Environment Variables

Create .env.local in project root:

env
# 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=3

Get your credentials:

  • MessageBird Access Key from MessageBird Dashboard under Developers > Access Keys
  • Supabase credentials from Supabase Dashboard > Project Settings > API
  • Never commit .env.local to 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:

sql
-- 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:

typescript
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:

typescript
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:

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
 */
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(), not Math.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:

typescript
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:

typescript
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:

typescript
'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:

typescript
'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:

typescript
'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

bash
npm run dev

Navigate to http://localhost:3000

Test Flow

  1. Request OTP:

    • Enter phone number in E.164 format: +15551234567
    • Click "Send OTP"
    • Check phone for SMS message
  2. Verify OTP:

    • Enter 6-digit code from SMS
    • Click "Verify OTP"
    • View success message

Common Test Cases

Test CaseExpected 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

  1. Crypto-Secure OTP Generation

    • Uses crypto.randomInt() instead of Math.random()
    • 6-digit codes provide 1,000,000 combinations
  2. Timing Attack Prevention

    • Constant-time comparison in secureCompare()
    • Prevents attackers from guessing OTP via response timing
  3. Brute Force Protection

    • Maximum 3 verification attempts per OTP
    • After 3 failed attempts, OTP invalidated
  4. 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
  5. OTP Expiration

    • 5-minute expiration window
    • Reduces attack surface time
  6. OTP Reuse Prevention

    • verified flag prevents code reuse
    • Once verified, OTP cannot be used again
  7. Database Security

    • Row Level Security (RLS) enabled
    • Service role required for API operations
    • Phone number format validation at DB level
  8. Environment Variable Security

    • Sensitive keys in .env.local (never committed)
    • Service role key only used server-side
    • Client-only gets public anon key

Additional Recommendations

  1. HTTPS Only in Production

    • Never send OTPs over unencrypted connections
    • Use Vercel/Netlify for automatic HTTPS
  2. Audit Logging

    • Log all OTP requests and verification attempts
    • Monitor for suspicious patterns
    • Store logs in separate secure location
  3. Input Sanitization

    • Validate phone number format (E.164)
    • Validate OTP format (6 digits)
    • Reject invalid inputs before processing
  4. 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

11. Error Handling for MessageBird and Supabase

MessageBird Error Codes

ErrorCauseSolution
Error 2Invalid access keyVerify MESSAGEBIRD_ACCESS_KEY in .env.local
Error 9Insufficient balanceAdd credits to MessageBird account
Error 21Invalid originatorUse valid phone number or alphanumeric (≤11 chars)
Error 25Invalid recipientEnsure phone number in E.164 format

MessageBird API Error Reference

Supabase Error Handling

typescript
// 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

Vercel is the recommended platform for Next.js applications with automatic optimizations.

  1. Install Vercel CLI:

    bash
    npm install -g vercel
  2. Deploy:

    bash
    vercel
  3. Configure Environment Variables:

    • Go to Vercel Dashboard > Project > Settings > Environment Variables
    • Add all variables from .env.local
    • Ensure SUPABASE_SERVICE_ROLE_KEY is only accessible server-side
  4. 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:

env
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=3

Post-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:

  1. Invalid phone number format (must be E.164 international format)
  2. MessageBird account has insufficient SMS credits
  3. Mobile carrier blocking or filtering SMS OTP messages
  4. 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:

  1. Verify key in MessageBird Dashboard > Developers > Access Keys
  2. Ensure MESSAGEBIRD_ACCESS_KEY in .env.local matches
  3. Restart Next.js dev server after changing .env.local
  4. Check for extra spaces or quotes in environment variable

Supabase Connection Errors

Possible Causes:

  1. Incorrect Supabase URL or keys
  2. RLS policies blocking access
  3. 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:

bash
npm install @upstash/redis
typescript
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:

  • 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

  1. Email OTP Alternative

  2. Resend OTP Functionality

    • Add "Resend Code" button after 60 seconds
    • Track resend attempts separately from verification attempts
  3. Multi-Language Support

    • Localize SMS messages based on phone country code
    • Use i18n for frontend messages
  4. OTP Analytics Dashboard

    • Track delivery rates, failure reasons
    • Monitor suspicious patterns
    • Visualize usage metrics
  5. Backup Authentication Methods

    • Authenticator app (TOTP) as alternative
    • WebAuthn/passkeys for passwordless
    • Email magic links
  6. Advanced Rate Limiting

    • Progressive delays (exponential backoff)
    • IP-based rate limiting in addition to phone
    • CAPTCHA after repeated failures
  7. 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:

typescript
// 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

Web 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:

  • 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'.