Use Case

WhatsApp OTP Authentication: Free SMS Alternative for 2025

Replace SMS OTP with WhatsApp two-factor authentication. Lower costs (90% savings), higher delivery rates, better UX. Full working code in Node.js, database schema, and production security practices included.

February 23, 2026By Retention Stack

WhatsApp OTP Authentication: Free SMS Alternative for 2025

Meta Description: Replace SMS OTP with WhatsApp two-factor authentication. Lower costs, higher delivery rates, better UX. Complete guide with code examples.


Introduction

SMS OTP authentication is yesterday's problem.

Your users expect WhatsApp. Your SMS provider charges $0.01–$0.05 per message. Delivery times vary. And every user who doesn't have SMS forwarding enabled might bounce out of your registration flow.

Enter WhatsApp OTP authentication—the modern replacement that's:

  • Faster: Delivered in seconds (vs. SMS delays)
  • Cheaper: ~90% less than SMS services
  • Better UX: Notifications appear on the same app users live in
  • Lower bounce: Nobody ignores WhatsApp notifications

In this guide, you'll implement WhatsApp OTP authentication from scratch. By the end, you'll have a fully functional two-factor authentication system that works better than SMS—and costs a fraction of the price.


Why WhatsApp OTP Beats SMS (Numbers That Matter)

Cost Comparison

ProviderCost/OTPAnnual (1M users)
Twilio SMS$0.0075$7,500
AWS SNS$0.00645$6,450
MessageBird SMS$0.01$10,000
WhatsApp API$0.001–$0.005$1,000–$5,000

You save $2,000–$9,000 per million users annually.

Delivery & User Experience

MetricSMSWhatsApp
Avg. Delivery Time3–8 seconds1–2 seconds
Delivery Rate94–98%99%+
User EngagementSee notification in messages appPop-up inside WhatsApp
Copy-Paste UXManual (error-prone)1-tap autofill

How WhatsApp OTP Works

The Flow

User enters email/phone
       ↓
Backend generates OTP (6 digits)
       ↓
Backend sends OTP via WhatsApp
       ↓
User receives in WhatsApp (1-2s)
       ↓
User enters OTP or taps autofill
       ↓
Backend verifies & issues session token
       ↓
User logged in

Why it's faster than SMS: WhatsApp push notifications wake the app instantly. SMS waits for the phone's messaging service.

Why it's cheaper: The WhatsApp API on RapidAPI charges per message, not per SMS service provider markup.


Step 1: Project Setup

Prerequisites

  • Node.js 16+
  • A RapidAPI account (free)
  • One test WhatsApp account (your personal account)
  • Basic Express.js knowledge

Initialize Project

bash
mkdir whatsapp-otp-auth
cd whatsapp-otp-auth
npm init -y
npm install express axios dotenv bcryptjs jsonwebtoken cors sqlite3
npm install --save-dev nodemon

Create structure

bash
mkdir -p src/{routes,controllers,models,utils,config}
touch src/index.js
touch .env

Update package.json scripts

json
{
  "scripts": {
    "dev": "nodemon src/index.js",
    "start": "node src/index.js"
  }
}

Environment file (.env)

PORT=3000
NODE_ENV=development

RAPIDAPI_KEY=your_api_key_here
RAPIDAPI_HOST=whatsapp-messaging-bot.p.rapidapi.com
WHATSAPP_SESSION=default

JWT_SECRET=your-super-secret-key-change-in-prod
OTP_EXPIRY_MINUTES=10

Step 2: Database Schema (SQLite)

Create src/config/database.js:

javascript
const sqlite3 = require('sqlite3').verbose();
const path = require('path');

const db = new sqlite3.Database(
  path.join(__dirname, '../../auth.db'),
  (err) => {
    if (err) console.error('DB connection error:', err);
    else console.log('✅ SQLite database connected');
  }
);

// Initialize tables
db.serialize(() => {
  // Users table
  db.run(`
    CREATE TABLE IF NOT EXISTS users (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      email TEXT UNIQUE NOT NULL,
      phone_number TEXT UNIQUE NOT NULL,
      password_hash TEXT,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      last_login DATETIME
    )
  `);

  // OTP table
  db.run(`
    CREATE TABLE IF NOT EXISTS otps (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      phone_number TEXT NOT NULL,
      otp_code TEXT NOT NULL,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      expires_at DATETIME NOT NULL,
      verified INTEGER DEFAULT 0,
      attempt_count INTEGER DEFAULT 0
    )
  `);

  // Sessions table
  db.run(`
    CREATE TABLE IF NOT EXISTS sessions (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      user_id INTEGER NOT NULL,
      token TEXT UNIQUE NOT NULL,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      expires_at DATETIME NOT NULL,
      FOREIGN KEY(user_id) REFERENCES users(id)
    )
  `);

  console.log('✅ Database tables initialized');
});

module.exports = db;

Step 3: WhatsApp Service Integration

Create src/utils/whatsappService.js:

javascript
const axios = require('axios');

class WhatsAppOTPService {
  constructor(apiKey, apiHost, sessionName) {
    this.apiKey = apiKey;
    this.apiHost = apiHost;
    this.sessionName = sessionName;
    this.baseUrl = `https://${apiHost}`;
  }

  async sendOTP(phoneNumber, otpCode) {
    try {
      const message = `🔐 Your verification code is: ${otpCode}\n\nExpires in 10 minutes. Don't share this code.`;

      const response = await axios.post(
        `${this.baseUrl}/v1/sendText`,
        {
          chatId: phoneNumber, // Pure phone number
          text: message,
          session: this.sessionName
        },
        {
          headers: {
            'x-rapidapi-key': this.apiKey,
            'x-rapidapi-host': this.apiHost,
          },
        }
      );

      console.log(`✅ OTP sent to ${phoneNumber}`);
      return { success: true, messageId: response.data.id };
    } catch (error) {
      console.error(`❌ Failed to send OTP to ${phoneNumber}:`, error.message);
      throw new Error(`WhatsApp service error: ${error.message}`);
    }
  }

  async sendVerificationMessage(phoneNumber, userName) {
    try {
      const message = `👋 Welcome, ${userName}! Your account is now verified on WhatsApp.`;

      await axios.post(
        `${this.baseUrl}/v1/sendText`,
        {
          chatId: phoneNumber,
          text: message,
          session: this.sessionName
        },
        {
          headers: {
            'x-rapidapi-key': this.apiKey,
            'x-rapidapi-host': this.apiHost,
          },
        }
      );

      return { success: true };
    } catch (error) {
      console.error('Error sending verification message:', error.message);
      throw error;
    }
  }
}

module.exports = WhatsAppOTPService;

Step 4: OTP Controller Logic

Create src/controllers/authController.js:

javascript
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const db = require('../config/database');
const WhatsAppOTPService = require('../utils/whatsappService');

const whatsapp = new WhatsAppOTPService(
  process.env.RAPIDAPI_KEY,
  process.env.RAPIDAPI_HOST,
  process.env.WHATSAPP_SESSION
);

// Helper to run DB queries
function dbRun(query, params) {
  return new Promise((resolve, reject) => {
    db.run(query, params, function(err) {
      if (err) reject(err);
      else resolve(this);
    });
  });
}

function dbGet(query, params) {
  return new Promise((resolve, reject) => {
    db.get(query, params, (err, row) => {
      if (err) reject(err);
      else resolve(row);
    });
  });
}

// Generate 6-digit OTP
function generateOTP() {
  return Math.floor(100000 + Math.random() * 900000).toString();
}

// Generate JWT token
function createToken(userId) {
  return jwt.sign(
    { userId, timestamp: Date.now() },
    process.env.JWT_SECRET,
    { expiresIn: '7d' }
  );
}

// Step 1: User requests OTP
exports.requestOTP = async (req, res) => {
  try {
    const { phoneNumber } = req.body;

    if (!phoneNumber || !/^\d{10,15}$/.test(phoneNumber)) {
      return res.status(400).json({ error: 'Invalid phone number format' });
    }

    // Generate OTP
    const otpCode = generateOTP();
    const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes

    // Save OTP to database
    await dbRun(
      'INSERT INTO otps (phone_number, otp_code, expires_at) VALUES (?, ?, ?)',
      [phoneNumber, otpCode, expiresAt.toISOString()]
    );

    // Send via WhatsApp
    await whatsapp.sendOTP(phoneNumber, otpCode);

    res.json({
      success: true,
      message: 'OTP sent to WhatsApp',
      phoneNumber,
      // In production, DO NOT return the OTP. This is for testing only.
      ...(process.env.NODE_ENV === 'development' && { otpCode }),
    });
  } catch (error) {
    console.error('Error requesting OTP:', error);
    res.status(500).json({ error: error.message });
  }
};

// Step 2: User verifies OTP
exports.verifyOTP = async (req, res) => {
  try {
    const { phoneNumber, otpCode, email, password } = req.body;

    if (!phoneNumber || !otpCode) {
      return res.status(400).json({ error: 'Phone number and OTP required' });
    }

    // Find valid OTP
    const otpRecord = await dbGet(
      `SELECT * FROM otps 
       WHERE phone_number = ? 
       AND otp_code = ? 
       AND expires_at > datetime('now')
       AND verified = 0
       ORDER BY created_at DESC 
       LIMIT 1`,
      [phoneNumber, otpCode]
    );

    if (!otpRecord) {
      return res.status(401).json({
        error: 'Invalid or expired OTP. Request a new one.',
      });
    }

    // Mark OTP as verified
    await dbRun(
      'UPDATE otps SET verified = 1 WHERE id = ?',
      [otpRecord.id]
    );

    // Check if user exists
    let user = await dbGet(
      'SELECT id FROM users WHERE phone_number = ?',
      [phoneNumber]
    );

    // If not, create user
    if (!user) {
      if (!email || !password) {
        return res.status(400).json({
          error: 'Email and password required for new users',
        });
      }

      const passwordHash = await bcrypt.hash(password, 10);

      const result = await dbRun(
        'INSERT INTO users (email, phone_number, password_hash) VALUES (?, ?, ?)',
        [email, phoneNumber, passwordHash]
      );

      user = { id: result.lastID };

      // Send welcome message
      try {
        await whatsapp.sendVerificationMessage(phoneNumber, email.split('@')[0]);
      } catch (err) {
        console.warn('Welcome message failed:', err.message);
      }
    }

    // Create session
    const token = createToken(user.id);
    const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days

    await dbRun(
      'INSERT INTO sessions (user_id, token, expires_at) VALUES (?, ?, ?)',
      [user.id, token, expiresAt.toISOString()]
    );

    // Update last login
    await dbRun(
      'UPDATE users SET last_login = datetime("now") WHERE id = ?',
      [user.id]
    );

    res.json({
      success: true,
      message: 'Authentication successful',
      token,
      user: { id: user.id, phoneNumber },
      expiresIn: '7d',
    });
  } catch (error) {
    console.error('Error verifying OTP:', error);
    res.status(500).json({ error: error.message });
  }
};

// Step 3: Validate token (for protected routes)
exports.validateToken = async (req, res, next) => {
  try {
    const token = req.headers.authorization?.replace('Bearer ', '');

    if (!token) {
      return res.status(401).json({ error: 'No token provided' });
    }

    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // Check session in database
    const session = await dbGet(
      'SELECT * FROM sessions WHERE token = ? AND expires_at > datetime("now")',
      [token]
    );

    if (!session) {
      return res.status(401).json({ error: 'Session expired or invalid' });
    }

    req.user = { id: decoded.userId };
    next();
  } catch (error) {
    res.status(401).json({ error: 'Invalid token' });
  }
};

Step 5: API Routes

Create src/routes/auth.js:

javascript
const express = require('express');
const authController = require('../controllers/authController');

const router = express.Router();

// Public routes
router.post('/request-otp', authController.requestOTP);
router.post('/verify-otp', authController.verifyOTP);

// Protected route (example)
router.get('/me', authController.validateToken, (req, res) => {
  res.json({ message: 'Protected content', userId: req.user.id });
});

module.exports = router;

Step 6: Express Server Setup

Create src/index.js:

javascript
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const authRoutes = require('./routes/auth');

const app = express();

// Middleware
app.use(cors());
app.use(express.json());

// Routes
app.use('/api/auth', authRoutes);

// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`🚀 Server running at http://localhost:${PORT}`);
  console.log(`📖 API docs: http://localhost:${PORT}/health`);
});

Testing the Full Flow

1. Request OTP

bash
curl -X POST http://localhost:3000/api/auth/request-otp \
  -H "Content-Type: application/json" \
  -d '{"phoneNumber": "14155552671"}'

Response (development mode shows OTP):

json
{
  "success": true,
  "message": "OTP sent to WhatsApp",
  "phoneNumber": "14155552671",
  "otpCode": "123456"
}

Check your WhatsApp—you'll get the OTP instantly.

2. Verify OTP

bash
curl -X POST http://localhost:3000/api/auth/verify-otp \
  -H "Content-Type: application/json" \
  -d '{
    "phoneNumber": "14155552671",
    "otpCode": "123456",
    "email": "user@example.com",
    "password": "securePassword123"
  }'

Response:

json
{
  "success": true,
  "message": "Authentication successful",
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "user": {"id": 1, "phoneNumber": "14155552671"},
  "expiresIn": "7d"
}

3. Use the Token

bash
curl -X GET http://localhost:3000/api/auth/me \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."

Security Best Practices

1. Rate Limiting on OTP Requests

Prevent brute force attacks:

javascript
const rateLimit = require('express-rate-limit');

const otpLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 3, // 3 OTP requests per window
  message: 'Too many OTP requests. Try again later.',
});

router.post('/request-otp', otpLimiter, authController.requestOTP);

2. Limit OTP Verification Attempts

javascript
// In authController.verifyOTP:
const maxAttempts = 5;
if (otpRecord.attempt_count >= maxAttempts) {
  return res.status(429).json({ error: 'Too many attempts. Request a new OTP.' });
}

// Increment fail attempt
await dbRun(
  'UPDATE otps SET attempt_count = attempt_count + 1 WHERE id = ?',
  [otpRecord.id]
);

3. Use HTTPS in Production

Always use TLS for token transmission.

javascript
// In production, force HTTPS
if (process.env.NODE_ENV === 'production') {
  app.use((req, res, next) => {
    if (!req.secure) {
      return res.redirect(`https://${req.headers.host}${req.url}`);
    }
    next();
  });
}

4. Never Store OTP in Logs

Use structured logging without sensitive data:

javascript
logger.info('OTP sent', { phoneNumber, timestamp: new Date() });
// ❌ NOT: logger.info(`OTP ${otpCode} sent to ${phoneNumber}`);

Frontend Integration (React Example)

javascript
import { useState } from 'react';

export default function WhatsAppOTPAuth() {
  const [step, setStep] = useState('phone'); // 'phone' or 'otp'
  const [phoneNumber, setPhoneNumber] = useState('');
  const [otpCode, setOtpCode] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [loading, setLoading] = useState(false);
  const [token, setToken] = useState(null);

  const handleRequestOTP = async (e) => {
    e.preventDefault();
    setLoading(true);
    try {
      const res = await fetch('/api/auth/request-otp', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ phoneNumber }),
      });
      const data = await res.json();
      if (data.success) {
        setStep('otp');
        alert('Check your WhatsApp for the OTP!');
      }
    } catch (error) {
      alert('Error: ' + error.message);
    }
    setLoading(false);
  };

  const handleVerifyOTP = async (e) => {
    e.preventDefault();
    setLoading(true);
    try {
      const res = await fetch('/api/auth/verify-otp', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ phoneNumber, otpCode, email, password }),
      });
      const data = await res.json();
      if (data.success) {
        setToken(data.token);
        localStorage.setItem('authToken', data.token);
        alert('✅ Logged in!');
        // Redirect to dashboard
      }
    } catch (error) {
      alert('Error: ' + error.message);
    }
    setLoading(false);
  };

  if (token) return <div>✅ Authenticated! Token: {token.substring(0, 20)}...</div>;

  if (step === 'phone') {
    return (
      <form onSubmit={handleRequestOTP}>
        <h2>WhatsApp Login</h2>
        <input
          type="tel"
          placeholder="Phone number (10-15 digits)"
          value={phoneNumber}
          onChange={(e) => setPhoneNumber(e.target.value)}
          required
        />
        <button type="submit" disabled={loading}>
          {loading ? 'Sending...' : 'Send OTP via WhatsApp'}
        </button>
      </form>
    );
  }

  return (
    <form onSubmit={handleVerifyOTP}>
      <h2>Enter Your Code</h2>
      <p>We sent a 6-digit code to your WhatsApp</p>
      <input
        type="text"
        placeholder="000000"
        value={otpCode}
        onChange={(e) => setOtpCode(e.target.value)}
        maxLength="6"
        required
      />
      {step === 'otp' && !email && (
        <>
          <input
            type="email"
            placeholder="Email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            required
          />
          <input
            type="password"
            placeholder="Password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            required
          />
        </>
      )}
      <button type="submit" disabled={loading}>
        {loading ? 'Verifying...' : 'Verify'}
      </button>
    </form>
  );
}

Cost Savings at Scale

Example: SaaS with 100,000 Monthly Active Users

SMS OTP (Twilio):

  • Cost per OTP: $0.0075
  • Assuming 1.5 OTP per signup: $1,125/month = $13,500/year

WhatsApp OTP (RapidAPI):

  • Cost per OTP: $0.003
  • Assuming 1.5 OTP per signup: $450/month = $5,400/year

Annual Savings: $8,100 (60% reduction)

At 1M users:

  • SMS: $135,000/year
  • WhatsApp: $54,000/year
  • Savings: $81,000/year

Monitoring & Troubleshooting

Track OTP Success Rate

javascript
const otpStats = {};

// In verifyOTP:
const success = otpRecord ? true : false;
otpStats.verified = (otpStats.verified || 0) + (success ? 1 : 0);
otpStats.failed = (otpStats.failed || 0) + (success ? 0 : 1);
otpStats.rate = (otpStats.verified / (otpStats.verified + otpStats.failed) * 100).toFixed(2) + '%';

console.log('OTP Stats:', otpStats);

Common Issues

ProblemFix
UserJWT verification failsToken expired. User needs to login again.
OTP not arrivingUser's WhatsApp not connected to session. Re-scan QR code.
Duplicate user errorUser already exists. Handle edge case in verifyOTP.
Rate limit hitCheck RapidAPI plan limits. Upgrade if needed.

Migration from SMS to WhatsApp

If you currently use SMS:

  1. 1.Offer WhatsApp OTP alongside SMS for 30 days
  2. 2.Track adoption – most users will prefer WhatsApp
  3. 3.Deprecate SMS once 80%+ use WhatsApp
  4. 4.Celebrate savings 🎉

Next Steps


Key Takeaways

  1. 1.WhatsApp OTP is 60% cheaper than SMS and delivers faster
  2. 2.Better UX – users already live in WhatsApp
  3. 3.Higher security – users can't forward codes like SMS
  4. 4.Easy migration – run parallel to SMS, then deprecate
  5. 5.RapidAPI makes it accessible – no approval forms, code in minutes

Ready to Implement?

Try Free on RapidAPI → Subscribe → Get API key → Copy the code above → Done.

Your users will thank you. Your CFO will too.

Ready to Get Started?

Try the WhatsApp API free on RapidAPI with no credit card required.

Try Free on RapidAPI