Twilio + Node.js: Implementing WhatsApp OTP for Secure User Verification

How to integrate Twilio WhatsApp OTP in Node.js

The Problem: Why SMS OTP Is Failing Your Users

SMS OTP has a SIM-swapping problem. Attackers convince carriers to transfer a victim’s phone number to a SIM they control, intercepting every verification code you send. Beyond security, SMS delivery rates in Southeast Asia, Latin America, and parts of Africa drop below 60% due to carrier filtering and grey route termination.

WhatsApp reaches 2.7 billion monthly active users and delivers end-to-end encrypted messages over data connections, bypassing carrier infrastructure entirely. For SaaS products with international user bases, WhatsApp OTP delivers higher completion rates, lower fraud exposure, and better user experience than SMS.

The difficulty lies in Twilio’s WhatsApp approval process, sandbox configuration quirks, and the Verify API’s behavior differences from standard SMS flows. Most tutorials stop at “send a message.” This one covers production-ready verification with proper error handling, rate limiting, and token lifecycle management.

Tech Stack and Prerequisites

Before writing a single line of code, confirm you have the following:

Accounts and credentials:

  • Twilio account with a verified phone number (twilio.com/try-twilio)
  • Twilio WhatsApp Sandbox enabled (Console > Messaging > Try it Out > Send a WhatsApp message)
  • For production: Twilio WhatsApp Business Profile approved (2-5 business day review)

Local environment:

  • Node.js v20.0.0 or higher (node --version to confirm)
  • npm v10+ or pnpm v9+
  • A tool for testing HTTP endpoints: Postman, Insomnia, or curl

Twilio credentials you will need:

  • TWILIO_ACCOUNT_SID – found on your Twilio Console dashboard
  • TWILIO_AUTH_TOKEN – found on your Twilio Console dashboard
  • TWILIO_VERIFY_SERVICE_SID – created in Step 1 below

npm packages (exact versions tested):

  • twilio@5.3.0 – official Twilio Node.js helper library
  • express@4.19.2 – HTTP server
  • dotenv@16.4.5 – environment variable management
  • express-rate-limit@7.3.1 – rate limiting middleware
  • zod@3.23.8 – request validation

Step 1: Project Setup and Twilio Verify Service Creation

Initialize the project

mkdir whatsapp-otp-demo && cd whatsapp-otp-demo
npm init -y
npm install twilio@5.3.0 express@4.19.2 dotenv@16.4.5 express-rate-limit@7.3.1 zod@3.23.8

Create the Verify Service in Twilio Console

A Verify Service is Twilio’s managed OTP system. It handles code generation, expiry (10 minutes by default), and attempt limits (5 max). You do not generate or store codes yourself.

  1. Log in to console.twilio.com
  2. Navigate to Verify > Services
  3. Click Create new Service
  4. Set Service Name to your app name (e.g., MyApp Verification)
  5. Under Channels, enable WhatsApp
  6. Copy the Service SID (starts with VA)

Project file structure

whatsapp-otp-demo/
├── src/
│   ├── config/
│   │   └── twilio.js        # Twilio client initialization
│   ├── middleware/
│   │   └── rateLimiter.js   # OTP rate limiting
│   ├── routes/
│   │   └── verification.js  # OTP send and check routes
│   └── server.js            # Express app entry point
├── .env                     # Environment variables (never commit)
├── .env.example             # Template for other developers
└── package.json

Step 2: Environment Configuration

Create .env

# .env - DO NOT COMMIT THIS FILE
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_auth_token_here
TWILIO_VERIFY_SERVICE_SID=VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
PORT=3000
NODE_ENV=development

Create .env.example for your team

# .env.example - Commit this file, not .env
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_VERIFY_SERVICE_SID=
PORT=3000
NODE_ENV=development

Add .gitignore

echo ".env" >> .gitignore
echo "node_modules/" >> .gitignore

Initialize Twilio client

Create src/config/twilio.js:

// src/config/twilio.js
import twilio from 'twilio';

// Validate required environment variables at startup
const requiredEnvVars = [
  'TWILIO_ACCOUNT_SID',
  'TWILIO_AUTH_TOKEN',
  'TWILIO_VERIFY_SERVICE_SID'
];

const missing = requiredEnvVars.filter(key => !process.env[key]);

if (missing.length > 0) {
  throw new Error(
    `Missing required environment variables: ${missing.join(', ')}\n` +
    'Copy .env.example to .env and fill in your credentials.'
  );
}

const client = twilio(
  process.env.TWILIO_ACCOUNT_SID,
  process.env.TWILIO_AUTH_TOKEN
);

export const verifyServiceSid = process.env.TWILIO_VERIFY_SERVICE_SID;
export default client;

Step 3: Core Implementation

Rate limiter middleware

Create src/middleware/rateLimiter.js:

// src/middleware/rateLimiter.js
import rateLimit from 'express-rate-limit';

// Limit OTP send requests: 3 per phone number per 10 minutes
export const otpSendLimiter = rateLimit({
  windowMs: 10 * 60 * 1000, // 10 minutes
  max: 3,
  keyGenerator: (req) => {
    // Rate limit by phone number, not IP
    // IP limiting is bypassable; phone number is not
    return req.body?.phoneNumber ?? req.ip;
  },
  message: {
    error: 'TOO_MANY_REQUESTS',
    message: 'Too many OTP requests for this number. Wait 10 minutes before trying again.',
    retryAfter: 600
  },
  standardHeaders: true,
  legacyHeaders: false
});

// Limit OTP verification attempts: 10 per IP per 15 minutes
export const otpVerifyLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  message: {
    error: 'TOO_MANY_ATTEMPTS',
    message: 'Too many verification attempts. Wait 15 minutes.',
    retryAfter: 900
  },
  standardHeaders: true,
  legacyHeaders: false
});

Verification routes

Create src/routes/verification.js:

// src/routes/verification.js
import { Router } from 'express';
import { z } from 'zod';
import twilioClient, { verifyServiceSid } from '../config/twilio.js';
import { otpSendLimiter, otpVerifyLimiter } from '../middleware/rateLimiter.js';

const router = Router();

// Validation schemas
const sendOtpSchema = z.object({
  phoneNumber: z
    .string()
    .regex(/^\+[1-9]\d{7,14}$/, {
      message: 'Phone number must be in E.164 format (e.g., +14155552671)'
    })
});

const verifyOtpSchema = z.object({
  phoneNumber: z
    .string()
    .regex(/^\+[1-9]\d{7,14}$/),
  code: z
    .string()
    .length(6, { message: 'OTP must be exactly 6 digits' })
    .regex(/^\d+$/, { message: 'OTP must contain only digits' })
});

/**
 * POST /api/verification/send
 * Sends a WhatsApp OTP to the provided phone number
 * Body: { phoneNumber: "+14155552671" }
 */
router.post('/send', otpSendLimiter, async (req, res) => {
  // Validate request body
  const parseResult = sendOtpSchema.safeParse(req.body);

  if (!parseResult.success) {
    return res.status(400).json({
      error: 'VALIDATION_ERROR',
      details: parseResult.error.flatten().fieldErrors
    });
  }

  const { phoneNumber } = parseResult.data;

  try {
    const verification = await twilioClient.verify.v2
      .services(verifyServiceSid)
      .verifications
      .create({
        to: `whatsapp:${phoneNumber}`, // WhatsApp channel prefix
        channel: 'whatsapp'
      });

    // Never return the actual code - Twilio manages it
    return res.status(200).json({
      success: true,
      status: verification.status, // 'pending'
      message: `OTP sent to WhatsApp number ending in ${phoneNumber.slice(-4)}`,
      expiresIn: 600 // 10 minutes
    });

  } catch (err) {
    // Handle Twilio-specific errors with useful messages
    if (err.code === 60200) {
      return res.status(400).json({
        error: 'INVALID_PHONE_NUMBER',
        message: 'The phone number format is invalid or unsupported.'
      });
    }

    if (err.code === 60203) {
      return res.status(429).json({
        error: 'MAX_SEND_ATTEMPTS',
        message: 'Maximum send attempts reached. Wait 10 minutes.'
      });
    }

    console.error('[OTP Send Error]', {
      code: err.code,
      message: err.message,
      phoneNumberSuffix: phoneNumber.slice(-4)
    });

    return res.status(500).json({
      error: 'SEND_FAILED',
      message: 'Failed to send OTP. Please try again.'
    });
  }
});

/**
 * POST /api/verification/verify
 * Checks the OTP entered by the user
 * Body: { phoneNumber: "+14155552671", code: "123456" }
 */
router.post('/verify', otpVerifyLimiter, async (req, res) => {
  const parseResult = verifyOtpSchema.safeParse(req.body);

  if (!parseResult.success) {
    return res.status(400).json({
      error: 'VALIDATION_ERROR',
      details: parseResult.error.flatten().fieldErrors
    });
  }

  const { phoneNumber, code } = parseResult.data;

  try {
    const verificationCheck = await twilioClient.verify.v2
      .services(verifyServiceSid)
      .verificationChecks
      .create({
        to: `whatsapp:${phoneNumber}`,
        code
      });

    if (verificationCheck.status === 'approved') {
      // At this point, mark the user as verified in your database
      // Example: await UserRepository.markPhoneVerified(phoneNumber);

      return res.status(200).json({
        success: true,
        verified: true,
        message: 'Phone number verified successfully.'
      });
    }

    // Status is 'pending' - code was wrong but attempts remain
    return res.status(400).json({
      success: false,
      verified: false,
      error: 'INVALID_CODE',
      message: 'The code you entered is incorrect. Please try again.'
    });

  } catch (err) {
    // Error code 60202: Verification not found (expired or already used)
    if (err.code === 60202) {
      return res.status(400).json({
        error: 'VERIFICATION_EXPIRED',
        message: 'This OTP has expired or was already used. Request a new code.'
      });
    }

    console.error('[OTP Verify Error]', {
      code: err.code,
      message: err.message,
      phoneNumberSuffix: phoneNumber.slice(-4)
    });

    return res.status(500).json({
      error: 'VERIFY_FAILED',
      message: 'Verification check failed. Please try again.'
    });
  }
});

export default router;

Express server entry point

Create src/server.js:

// src/server.js
import 'dotenv/config';
import express from 'express';
import verificationRouter from './routes/verification.js';

const app = express();
const PORT = process.env.PORT ?? 3000;

// Parse JSON request bodies
app.use(express.json());

// Trust proxy if running behind Nginx or a load balancer
// Required for accurate IP-based rate limiting
if (process.env.NODE_ENV === 'production') {
  app.set('trust proxy', 1);
}

// Mount verification routes
app.use('/api/verification', verificationRouter);

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

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
  console.log(`Environment: ${process.env.NODE_ENV}`);
});

Update package.json for ES modules

{
  "type": "module",
  "scripts": {
    "start": "node src/server.js",
    "dev": "node --watch src/server.js"
  }
}

Step 4: Testing the Integration

Start the server

npm run dev

Expected output:

Server running on port 3000
Environment: development

Test OTP send with curl

curl -X POST http://localhost:3000/api/verification/send \
  -H "Content-Type: application/json" \
  -d '{"phoneNumber": "+14155552671"}'

Expected response:

{
  "success": true,
  "status": "pending",
  "message": "OTP sent to WhatsApp number ending in 2671",
  "expiresIn": 600
}

Test OTP verification with curl

curl -X POST http://localhost:3000/api/verification/verify \
  -H "Content-Type: application/json" \
  -d '{"phoneNumber": "+14155552671", "code": "123456"}'

Expected response on success:

{
  "success": true,
  "verified": true,
  "message": "Phone number verified successfully."
}

Test with Postman

  1. Create a new POST request to http://localhost:3000/api/verification/send
  2. Set Body to raw > JSON
  3. Send {"phoneNumber": "+YOUR_WHATSAPP_NUMBER"}
  4. Check your WhatsApp for the code
  5. Send the code to /api/verification/verify

Sandbox-specific testing note

In Twilio’s sandbox environment, only pre-approved phone numbers receive WhatsApp messages. Go to Console > Messaging > Try it Out > Send a WhatsApp message and follow the opt-in instructions with each test number by sending the sandbox join phrase from WhatsApp.

Common Errors and Troubleshooting

Gotcha 1: “Channel ‘whatsapp’ is not enabled for this service”

Error: Twilio returns error code 60238

Cause: The Verify Service does not have WhatsApp enabled. This happens when the service was created before enabling the WhatsApp channel, or the channel was never explicitly activated.

Fix:

  1. Go to Twilio Console > Verify > Services
  2. Select your service
  3. Click General in the sidebar
  4. Under Other channels, toggle WhatsApp on
  5. Save and retry

Gotcha 2: Messages sending to SMS instead of WhatsApp

Cause: The to field in the API call is missing the whatsapp: prefix. Without it, Twilio defaults to SMS.

Wrong:

to: phoneNumber           // Sends SMS

Correct:

to: `whatsapp:${phoneNumber}`  // Sends WhatsApp message

Also confirm the channel: 'whatsapp' parameter is explicitly set in the verifications.create() call.

Gotcha 3: Sandbox messages not received

Cause: The recipient’s WhatsApp number has not joined the Twilio sandbox.

Fix: Every test phone number must opt in by sending the sandbox keyword (e.g., join silver-tiger) to your Twilio sandbox WhatsApp number (+14155238886 for most regions). The opt-in expires after 72 hours of inactivity, so testers who have not used the sandbox recently must rejoin.

Production note: This sandbox requirement disappears once your WhatsApp Business Profile is approved. Production accounts send to any WhatsApp number without opt-in.

Gotcha 4: Rate limit errors during development

Error: Twilio returns error code 60203 (“Max send attempts reached”)

Cause: Twilio limits OTP sends to the same number to 5 per 10-minute window at the service level, regardless of your application-level rate limiter.

Fix for development: Use different test phone numbers or wait the 10-minute window. Do not attempt to bypass this limit. It exists to prevent your Twilio account from being used to spam users. In production, your application-level rate limiter (3 per 10 minutes) hits before Twilio’s limit (5 per 10 minutes), providing the buffer.

Security Checklist

Apply these controls before shipping to production:

  • Never log full phone numbers. Log only the last 4 digits (phoneNumber.slice(-4)) in all error and info logs.
  • Never return the OTP in any API response. Twilio generates and delivers it. Your API has no business knowing the code value.
  • Set TWILIO_AUTH_TOKEN rotation reminders. Rotate credentials every 90 days and immediately after any suspected exposure.
  • Enable Twilio request signing for webhook endpoints. Verify the X-Twilio-Signature header on any callbacks using twilio.validateRequest().
  • Store verified status in your database immediately after a successful verification check. Do not rely on re-verifying with Twilio.
  • Mark Verify tokens as consumed. Twilio automatically invalidates codes after one successful check, but build your system to reject re-verification of already-verified sessions at the application layer.
  • Use HTTPS in production. OTP endpoints transmit phone numbers. Never expose them over plain HTTP.
  • Implement IP-based rate limiting at the load balancer or API gateway level in addition to the application-level limiter in this tutorial. Two independent layers catch more abuse patterns.
  • Monitor Twilio usage alerts. Set spending caps in the Twilio Console under Billing > Manage > Balance Alerts to catch unexpected usage spikes caused by abuse.

Production Deployment Checklist

Before going live:

  • [ ] WhatsApp Business Profile approved in Twilio Console
  • [ ] Verify Service WhatsApp channel enabled
  • [ ] Environment variables stored in a secrets manager (AWS Secrets Manager, HashiCorp Vault, or Doppler), not in .env files on the server
  • [ ] Rate limiting tested under load
  • [ ] Error monitoring connected (Sentry, Datadog, or equivalent)
  • [ ] Twilio spending cap configured
  • [ ] HTTPS enforced on all endpoints
  • [ ] trust proxy set correctly for your infrastructure
  • [ ] Phone number logging scrubbed from all log outputs

Ready to Ship This Integration Without the Guesswork?

Connecting Twilio to your Node.js app is straightforward when the documentation matches your setup. In practice, WhatsApp Business Profile approvals, production infrastructure configuration, and edge case error handling introduce delays that derail launch timelines.

If you are building authentication infrastructure for a SaaS product and need it done right the first time, whether that is WhatsApp OTP, SMS fallback, email verification, or a full multi-factor authentication system, we can help you design and implement it correctly.

Schedule a free technical consultation and tell us what you are building. We will scope the work, identify the integration risks specific to your stack, and give you a clear implementation path before you write the first line of production code.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top