← Back to list

Implementing SMS Verification with Twilio: Complete Guide

I’ll be honest with you: before implementing SMS verification for the first time, I thought it would be straightforward. Send a code…

Osmion in JavaScript in Plain English · 2026-01-13 18:04 · 54 claps · 10.4 min read
#programming #sms-verification #software-development #software-engineering #computer-science
Open on Medium ↗
Wiki topics: 💻 · Programming 🔬 · Science · General

Implementing SMS Verification with Twilio: Complete Guide

I’ll be honest with you: before implementing SMS verification for the first time, I thought it would be straightforward. Send a code, verify it, done. Then I discovered phone number formatting nightmares, international prefixes, rate limiting, and the joy of users typing their verification code wrong five times in a row.

Let me save you from the mistakes I made and show you how to build a rock-solid SMS verification system using Twilio that actually works in production.

Why SMS Verification?

SMS verification adds a layer of security that email alone can’t provide. When someone signs up with a phone number, you know:

They have access to that phone right now (not just an email they created years ago) It’s harder to create fake accounts at scale (phone numbers cost money) You have a direct channel to reach them that has a 98% open rate

I implemented SMS verification for a freelance project last year where they were dealing with tons of fake signups. After adding phone verification, fake accounts dropped by 85%.

Getting Started with Twilio

First, you need a Twilio account. Sign up at twilio.com and you’ll get $15 in free credits to test with. For production, you’ll need to buy a phone number (about $1/month) and pay per SMS sent (around $0.0075 per message in the US, varies by country).

Install the Twilio SDK:

npm install twilio
# or
pip install twilio

The Database Schema

Before we write any code, let’s design how we’ll store verification attempts:

CREATE TABLE phone_verifications (
    id BIGSERIAL PRIMARY KEY,
    phone_number VARCHAR(20) NOT NULL,
    verification_code VARCHAR(10) NOT NULL,
    verified BOOLEAN DEFAULT FALSE,
    attempts INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMP NOT NULL,
    verified_at TIMESTAMP NULL,
    ip_address INET,
    INDEX idx_phone_number (phone_number),
    INDEX idx_expires_at (expires_at)
);

-- Track rate limiting
CREATE TABLE verification_rate_limits (
    phone_number VARCHAR(20) PRIMARY KEY,
    send_count INT DEFAULT 0,
    last_sent_at TIMESTAMP,
    blocked_until TIMESTAMP NULL
);

-- Store verified users
CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    phone_number VARCHAR(20) UNIQUE NOT NULL,
    verified BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Building the Verification Service

Here’s a complete implementation in Node.js:

const twilio = require('twilio');
const crypto = require('crypto');

class SMSVerificationService {
    constructor() {
        this.client = twilio(
            process.env.TWILIO_ACCOUNT_SID,
            process.env.TWILIO_AUTH_TOKEN
        );
        this.fromNumber = process.env.TWILIO_PHONE_NUMBER;

        // Configuration
        this.codeLength = 6;
        this.codeExpiry = 10; // minutes
        this.maxAttempts = 5;
        this.rateLimitWindow = 60; // minutes
        this.maxSendsPerWindow = 3;
    }

    // Format phone number to E.164 format
    formatPhoneNumber(phone) {
        // Remove all non-digits
        let cleaned = phone.replace(/\D/g, '');

        // If it starts with 0, assume it's missing country code
        if (cleaned.startsWith('0')) {
            cleaned = '1' + cleaned.substring(1); // Default to US
        }

        // Add + prefix if missing
        if (!cleaned.startsWith('+')) {
            cleaned = '+' + cleaned;
        }

        return cleaned;
    }

    // Generate a random numeric code
    generateCode() {
        return Math.floor(
            Math.pow(10, this.codeLength - 1) + 
            Math.random() * 9 * Math.pow(10, this.codeLength - 1)
        ).toString();
    }

    // Check rate limiting
    async checkRateLimit(phoneNumber) {
        const limit = await db.query(
            'SELECT * FROM verification_rate_limits WHERE phone_number = $1',
            [phoneNumber]
        );

        if (limit.rows.length === 0) {
            return { allowed: true };
        }

        const record = limit.rows[0];

        // Check if blocked
        if (record.blocked_until && new Date(record.blocked_until) > new Date()) {
            return {
                allowed: false,
                reason: 'Too many attempts. Please try again later.',
                blockedUntil: record.blocked_until
            };
        }

        // Check rate limit window
        const windowStart = new Date(Date.now() - this.rateLimitWindow * 60 * 1000);
        if (new Date(record.last_sent_at) > windowStart) {
            if (record.send_count >= this.maxSendsPerWindow) {
                // Block for 1 hour
                const blockedUntil = new Date(Date.now() + 60 * 60 * 1000);
                await db.query(
                    'UPDATE verification_rate_limits SET blocked_until = $1 WHERE phone_number = $2',
                    [blockedUntil, phoneNumber]
                );

                return {
                    allowed: false,
                    reason: 'Too many verification requests. Try again in 1 hour.',
                    blockedUntil
                };
            }
        }

        return { allowed: true };
    }

    // Send verification code
    async sendVerificationCode(phoneNumber, ipAddress = null) {
        try {
            // Format phone number
            const formattedPhone = this.formatPhoneNumber(phoneNumber);

            // Check rate limiting
            const rateLimitCheck = await this.checkRateLimit(formattedPhone);
            if (!rateLimitCheck.allowed) {
                return {
                    success: false,
                    error: rateLimitCheck.reason,
                    blockedUntil: rateLimitCheck.blockedUntil
                };
            }

            // Generate code
            const code = this.generateCode();
            const expiresAt = new Date(Date.now() + this.codeExpiry * 60 * 1000);

            // Store in database
            await db.query(`
                INSERT INTO phone_verifications 
                (phone_number, verification_code, expires_at, ip_address)
                VALUES ($1, $2, $3, $4)
            `, [formattedPhone, code, expiresAt, ipAddress]);

            // Update rate limit
            await db.query(`
                INSERT INTO verification_rate_limits 
                (phone_number, send_count, last_sent_at)
                VALUES ($1, 1, NOW())
                ON CONFLICT (phone_number) DO UPDATE
                SET send_count = CASE 
                    WHEN verification_rate_limits.last_sent_at < NOW() - INTERVAL '${this.rateLimitWindow} minutes'
                    THEN 1
                    ELSE verification_rate_limits.send_count + 1
                END,
                last_sent_at = NOW(),
                blocked_until = NULL
            `, [formattedPhone]);

            // Send SMS via Twilio
            const message = await this.client.messages.create({
                body: `Your verification code is: ${code}. Valid for ${this.codeExpiry} minutes.`,
                to: formattedPhone,
                from: this.fromNumber
            });

            console.log(`Verification code sent to ${formattedPhone}: ${message.sid}`);

            return {
                success: true,
                messageId: message.sid,
                expiresIn: this.codeExpiry * 60 // seconds
            };

        } catch (error) {
            console.error('Error sending verification code:', error);

            if (error.code === 21211) {
                return {
                    success: false,
                    error: 'Invalid phone number format'
                };
            }

            if (error.code === 21608) {
                return {
                    success: false,
                    error: 'Phone number is not reachable'
                };
            }

            return {
                success: false,
                error: 'Failed to send verification code'
            };
        }
    }

    // Verify code
    async verifyCode(phoneNumber, code) {
        try {
            const formattedPhone = this.formatPhoneNumber(phoneNumber);

            // Get latest verification attempt
            const result = await db.query(`
                SELECT * FROM phone_verifications 
                WHERE phone_number = $1 
                AND verified = FALSE
                ORDER BY created_at DESC 
                LIMIT 1
            `, [formattedPhone]);

            if (result.rows.length === 0) {
                return {
                    success: false,
                    error: 'No verification request found'
                };
            }

            const verification = result.rows[0];

            // Check if expired
            if (new Date() > new Date(verification.expires_at)) {
                return {
                    success: false,
                    error: 'Verification code has expired'
                };
            }

            // Check max attempts
            if (verification.attempts >= this.maxAttempts) {
                return {
                    success: false,
                    error: 'Maximum verification attempts exceeded'
                };
            }

            // Increment attempt counter
            await db.query(
                'UPDATE phone_verifications SET attempts = attempts + 1 WHERE id = $1',
                [verification.id]
            );

            // Verify code
            if (verification.verification_code !== code) {
                const remainingAttempts = this.maxAttempts - (verification.attempts + 1);
                return {
                    success: false,
                    error: 'Invalid verification code',
                    remainingAttempts
                };
            }

            // Success! Mark as verified
            await db.query(`
                UPDATE phone_verifications 
                SET verified = TRUE, verified_at = NOW() 
                WHERE id = $1
            `, [verification.id]);

            // Update or create user
            await db.query(`
                INSERT INTO users (phone_number, verified)
                VALUES ($1, TRUE)
                ON CONFLICT (phone_number) DO UPDATE
                SET verified = TRUE
            `, [formattedPhone]);

            return {
                success: true,
                message: 'Phone number verified successfully'
            };

        } catch (error) {
            console.error('Error verifying code:', error);
            return {
                success: false,
                error: 'Verification failed'
            };
        }
    }

    // Resend code
    async resendCode(phoneNumber, ipAddress = null) {
        // Check if there's a recent unverified code
        const formattedPhone = this.formatPhoneNumber(phoneNumber);

        const existing = await db.query(`
            SELECT * FROM phone_verifications 
            WHERE phone_number = $1 
            AND verified = FALSE 
            AND expires_at > NOW()
            ORDER BY created_at DESC 
            LIMIT 1
        `, [formattedPhone]);

        if (existing.rows.length > 0) {
            // If code was sent less than 30 seconds ago, don't resend
            const lastSent = new Date(existing.rows[0].created_at);
            if (Date.now() - lastSent.getTime() < 30000) {
                return {
                    success: false,
                    error: 'Please wait before requesting another code',
                    retryAfter: 30 - Math.floor((Date.now() - lastSent.getTime()) / 1000)
                };
            }
        }

        // Send new code
        return await this.sendVerificationCode(phoneNumber, ipAddress);
    }
}

module.exports = new SMSVerificationService();

Creating the API Endpoints

Now let’s create the REST API:

const express = require('express');
const { body, validationResult } = require('express-validator');
const smsService = require('./sms-verification-service');

const router = express.Router();

// Send verification code
router.post('/verify/send', [
    body('phoneNumber').isMobilePhone('any', { strictMode: false })
], async (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
        return res.status(400).json({ 
            success: false,
            errors: errors.array() 
        });
    }

    const { phoneNumber } = req.body;
    const ipAddress = req.ip;

    const result = await smsService.sendVerificationCode(phoneNumber, ipAddress);

    if (!result.success) {
        return res.status(400).json(result);
    }

    res.json({
        success: true,
        message: 'Verification code sent',
        expiresIn: result.expiresIn
    });
});

// Verify code
router.post('/verify/check', [
    body('phoneNumber').isMobilePhone('any', { strictMode: false }),
    body('code').isLength({ min: 6, max: 6 }).isNumeric()
], async (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
        return res.status(400).json({ 
            success: false,
            errors: errors.array() 
        });
    }

    const { phoneNumber, code } = req.body;

    const result = await smsService.verifyCode(phoneNumber, code);

    if (!result.success) {
        return res.status(400).json(result);
    }

    // Generate session/JWT token here
    const token = generateAuthToken(phoneNumber);

    res.json({
        success: true,
        message: result.message,
        token
    });
});

// Resend code
router.post('/verify/resend', [
    body('phoneNumber').isMobilePhone('any', { strictMode: false })
], async (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
        return res.status(400).json({ 
            success: false,
            errors: errors.array() 
        });
    }

    const { phoneNumber } = req.body;
    const ipAddress = req.ip;

    const result = await smsService.resendCode(phoneNumber, ipAddress);

    if (!result.success) {
        return res.status(400).json(result);
    }

    res.json({
        success: true,
        message: 'Verification code resent'
    });
});

module.exports = router;

Building the Frontend

Here’s a React component for the verification flow:

import React, { useState, useEffect } from 'react';

function PhoneVerification({ onVerified }) {
    const [step, setStep] = useState('phone'); // 'phone' or 'code'
    const [phoneNumber, setPhoneNumber] = useState('');
    const [code, setCode] = useState('');
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState('');
    const [expiresIn, setExpiresIn] = useState(0);
    const [canResend, setCanResend] = useState(true);

    useEffect(() => {
        if (expiresIn > 0) {
            const timer = setInterval(() => {
                setExpiresIn(prev => Math.max(0, prev - 1));
            }, 1000);
            return () => clearInterval(timer);
        }
    }, [expiresIn]);

    const formatTime = (seconds) => {
        const mins = Math.floor(seconds / 60);
        const secs = seconds % 60;
        return `${mins}:${secs.toString().padStart(2, '0')}`;
    };

    const handleSendCode = async (e) => {
        e.preventDefault();
        setLoading(true);
        setError('');

        try {
            const response = await fetch('/api/verify/send', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ phoneNumber })
            });

            const data = await response.json();

            if (data.success) {
                setStep('code');
                setExpiresIn(data.expiresIn);
                setCanResend(false);
                setTimeout(() => setCanResend(true), 30000); // 30 seconds
            } else {
                setError(data.error || 'Failed to send code');
            }
        } catch (err) {
            setError('Network error. Please try again.');
        } finally {
            setLoading(false);
        }
    };

    const handleVerifyCode = async (e) => {
        e.preventDefault();
        setLoading(true);
        setError('');

        try {
            const response = await fetch('/api/verify/check', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ phoneNumber, code })
            });

            const data = await response.json();

            if (data.success) {
                onVerified(data.token);
            } else {
                setError(data.error || 'Invalid code');
                if (data.remainingAttempts !== undefined) {
                    setError(`${data.error}. ${data.remainingAttempts} attempts remaining.`);
                }
            }
        } catch (err) {
            setError('Network error. Please try again.');
        } finally {
            setLoading(false);
        }
    };

    const handleResend = async () => {
        setLoading(true);
        setError('');

        try {
            const response = await fetch('/api/verify/resend', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ phoneNumber })
            });

            const data = await response.json();

            if (data.success) {
                setCanResend(false);
                setTimeout(() => setCanResend(true), 30000);
                setError('Code resent successfully');
            } else {
                setError(data.error || 'Failed to resend code');
            }
        } catch (err) {
            setError('Network error. Please try again.');
        } finally {
            setLoading(false);
        }
    };

    if (step === 'phone') {
        return (
            <div style={{ maxWidth: '400px', margin: '0 auto', padding: '20px' }}>
                <h2>Verify Your Phone Number</h2>
                <form onSubmit={handleSendCode}>
                    <input
                        type="tel"
                        placeholder="+1 (555) 123-4567"
                        value={phoneNumber}
                        onChange={(e) => setPhoneNumber(e.target.value)}
                        style={{
                            width: '100%',
                            padding: '12px',
                            fontSize: '16px',
                            marginBottom: '10px',
                            border: '1px solid #ddd',
                            borderRadius: '4px'
                        }}
                        required
                    />
                    {error && <p style={{ color: 'red' }}>{error}</p>}
                    <button
                        type="submit"
                        disabled={loading}
                        style={{
                            width: '100%',
                            padding: '12px',
                            fontSize: '16px',
                            background: '#007bff',
                            color: 'white',
                            border: 'none',
                            borderRadius: '4px',
                            cursor: loading ? 'not-allowed' : 'pointer'
                        }}
                    >
                        {loading ? 'Sending...' : 'Send Code'}
                    </button>
                </form>
            </div>
        );
    }

    return (
        <div style={{ maxWidth: '400px', margin: '0 auto', padding: '20px' }}>
            <h2>Enter Verification Code</h2>
            <p>We sent a 6-digit code to {phoneNumber}</p>
            {expiresIn > 0 && (
                <p style={{ color: '#666' }}>
                    Code expires in {formatTime(expiresIn)}
                </p>
            )}
            <form onSubmit={handleVerifyCode}>
                <input
                    type="text"
                    placeholder="000000"
                    value={code}
                    onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
                    maxLength="6"
                    style={{
                        width: '100%',
                        padding: '12px',
                        fontSize: '24px',
                        marginBottom: '10px',
                        border: '1px solid #ddd',
                        borderRadius: '4px',
                        textAlign: 'center',
                        letterSpacing: '0.5em'
                    }}
                    required
                />
                {error && <p style={{ color: 'red' }}>{error}</p>}
                <button
                    type="submit"
                    disabled={loading || code.length !== 6}
                    style={{
                        width: '100%',
                        padding: '12px',
                        fontSize: '16px',
                        background: '#28a745',
                        color: 'white',
                        border: 'none',
                        borderRadius: '4px',
                        cursor: (loading || code.length !== 6) ? 'not-allowed' : 'pointer',
                        marginBottom: '10px'
                    }}
                >
                    {loading ? 'Verifying...' : 'Verify'}
                </button>
                <button
                    type="button"
                    onClick={handleResend}
                    disabled={!canResend || loading}
                    style={{
                        width: '100%',
                        padding: '12px',
                        fontSize: '14px',
                        background: 'transparent',
                        color: '#007bff',
                        border: '1px solid #007bff',
                        borderRadius: '4px',
                        cursor: (!canResend || loading) ? 'not-allowed' : 'pointer'
                    }}
                >
                    {canResend ? 'Resend Code' : 'Wait to resend...'}
                </button>
                <button
                    type="button"
                    onClick={() => setStep('phone')}
                    style={{
                        width: '100%',
                        padding: '12px',
                        fontSize: '14px',
                        background: 'transparent',
                        color: '#666',
                        border: 'none',
                        cursor: 'pointer',
                        marginTop: '10px'
                    }}
                >
                    Change Phone Number
                </button>
            </form>
        </div>
    );
}

export default PhoneVerification;

Handling International Numbers

International phone numbers are tricky. Here’s a better phone number parser using the libphonenumber library:

const phoneUtil = require('google-libphonenumber').PhoneNumberUtil.getInstance();

function parsePhoneNumber(phoneNumber, defaultCountry = 'US') {
    try {
        const number = phoneUtil.parse(phoneNumber, defaultCountry);

        if (!phoneUtil.isValidNumber(number)) {
            throw new Error('Invalid phone number');
        }

        return {
            valid: true,
            e164: phoneUtil.format(number, PhoneNumberFormat.E164),
            country: phoneUtil.getRegionCodeForNumber(number),
            type: phoneUtil.getNumberType(number) // MOBILE, FIXED_LINE, etc
        };
    } catch (error) {
        return {
            valid: false,
            error: error.message
        };
    }
}

Cost Optimization Tips

SMS isn’t free. Here’s how to keep costs down:

Use Twilio Verify API instead of sending your own SMS. It handles rate limiting and retry logic, and costs the same.

// Using Twilio Verify (recommended)
const verify = await client.verify.v2
    .services(process.env.TWILIO_VERIFY_SERVICE_SID)
    .verifications
    .create({ to: phoneNumber, channel: 'sms' });

const check = await client.verify.v2
    .services(process.env.TWILIO_VERIFY_SERVICE_SID)
    .verificationChecks
    .create({ to: phoneNumber, code: userCode });

Offer voice call as backup instead of SMS. Some users can’t receive SMS but can get calls.

Implement aggressive rate limiting to prevent abuse. Someone trying to verify 100 numbers an hour is probably up to no good.

Security Considerations

Don’t log verification codes. Ever. Not even in development.

Use HTTPS everywhere. Codes sent over HTTP are worthless for security.

Implement device fingerprinting to detect suspicious patterns.

Consider requiring CAPTCHA before sending codes to prevent automated abuse.

Store codes hashed if you’re paranoid (probably overkill for most cases since they expire quickly).

Common Pitfalls I’ve Encountered

Twilio can’t send SMS to VOIP numbers. Users with Google Voice or other VOIP services will have problems. Offer voice call as fallback.

Some carriers block short codes. Use a long code (regular phone number) instead.

Verification codes in SMS sometimes take 30+ seconds to arrive. Set your expiry time to at least 10 minutes, not 5.

Users often switch between tabs and forget which number they entered. Show the phone number on the verification screen.

People will try to verify the same number multiple times. Handle this gracefully instead of creating duplicate records.

Testing Without Burning Money

Twilio has a test mode with magic phone numbers that don’t cost anything:

// Test phone numbers (won't send real SMS)
const TEST_NUMBERS = {
    valid: '+15005550006',
    invalid: '+15005550001',
    undeliverable: '+15005550009'
};

if (process.env.NODE_ENV === 'test') {
    // Use test numbers
}

When Things Go Wrong

If users report not receiving codes, check:

Are you using a verified Twilio number? Is the phone number properly formatted in E.164? Is the user’s carrier blocking your number? (check Twilio logs) Did you hit your Twilio spending limit?

Twilio provides excellent logs. Use them. Every failed SMS has a reason code.

Monitoring and Alerts

Track these metrics:

Delivery rate (should be >95%) Time to delivery (should be <10 seconds average) Verification success rate Failed verification attempts Cost per verification

Integration with Authentication Systems

If you’re building a complete authentication system with payments for premium features, Dodo Payments integrates nicely with phone-based authentication for managing subscriptions and billing.

Alternative: Email + SMS Hybrid

For extra security, require both:

async function verifyUserIdentity(email, phoneNumber) {
    // Send email code
    const emailCode = await sendEmailVerification(email);

    // Send SMS code
    const smsCode = await smsService.sendVerificationCode(phoneNumber);

    // Require both to complete signup
    return {
        requiresEmail: true,
        requiresSMS: true
    };
}

The Bottom Line

SMS verification is straightforward once you understand the gotchas. The hardest parts aren’t the code itself but handling edge cases: international numbers, rate limiting, carrier issues, and cost management.

Start with Twilio Verify API if you want a quick implementation. Build your own if you need fine-grained control or want to learn the internals.

For more guides on authentication systems, API integrations, and building secure applications, check out Coders Stop where I regularly post practical tutorials.

Have you implemented SMS verification before? What challenges did you face? Share your experience in the comments!


메타데이터
post_id
8366f991929f
slug
implementing-sms-verification-with-twilio-complete-guide-8366f991929f
url
https://javascript.plainenglish.io/implementing-sms-verification-with-twilio-complete-guide-8366f991929f
canonical_url
https://javascript.plainenglish.io/implementing-sms-verification-with-twilio-complete-guide-8366f991929f
author_url
https://medium.com/@osmion
status
ok
fetched_at
2026-06-14 11:28:49