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.

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

| Provider | Cost/OTP | Annual (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

| Metric | SMS | WhatsApp | |--------|-----|----------| | Avg. Delivery Time | 3–8 seconds | 1–2 seconds | | Delivery Rate | 94–98% | 99%+ | | User Engagement | See notification in messages app | Pop-up inside WhatsApp | | Copy-Paste UX | Manual (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

✅ Authenticated! Token: {token.substring(0, 20)}...
;

if (step === 'phone') { return (

WhatsApp Login

setPhoneNumber(e.target.value)} required />
); }

return (

Enter Your Code

We sent a 6-digit code to your WhatsApp

setOtpCode(e.target.value)} maxLength="6" required /> {step === 'otp' && !email && ( <> setEmail(e.target.value)} required /> setPassword(e.target.value)} required /> )}
); }


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

| Problem | Fix | |---------|-----| | UserJWT verification fails | Token expired. User needs to login again. | | OTP not arriving | User's WhatsApp not connected to session. Re-scan QR code. | | Duplicate user error | User already exists. Handle edge case in verifyOTP. | | Rate limit hit | Check RapidAPI plan limits. Upgrade if needed. |


Migration from SMS to WhatsApp

If you currently use SMS:

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


Next Steps


Key Takeaways

1. WhatsApp OTP is 60% cheaper than SMS and delivers faster 2. Better UX – users already live in WhatsApp 3. Higher security – users can't forward codes like SMS 4. Easy migration – run parallel to SMS, then deprecate 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