SMS Verification Best Practices: Secure and User-Friendly Implementation
Implement secure SMS verification systems that balance security requirements with optimal user experience.
Table of Contents
Table of Contents
SMS Verification Best Practices: Secure and User-Friendly Implementation
SMS verification remains a cornerstone of modern authentication systems. Implementing secure, user-friendly SMS verification requires balancing security requirements with user experience while addressing delivery challenges and fraud prevention.
SMS Verification Best Practices Overview
Overview {#overview}
SMS verification (2FA/MFA via SMS) is widely used for account security, transaction confirmation, and identity verification. Success requires secure code generation, reliable delivery, fraud prevention, and excellent UX.
Critical Requirements:
- Security: Strong OTP generation, rate limiting, anti-phishing
- Delivery: High success rates, fast delivery, carrier compatibility
- Fraud Prevention: Bot detection, SMS pumping prevention, abuse monitoring
- UX: Easy verification flow, fallback options, clear messaging
Security Patterns {#security-patterns}
Robust security prevents unauthorized access and code interception.
OTP Security Best Practices
interface OTPConfig {
length: number
expirySeconds: number
allowedAttempts: number
alphanumeric: boolean
caseInsensitive: boolean
}
const SECURITY_CONFIGS: Record<string, OTPConfig> = {
'standard': {
length: 6,
expirySeconds: 300, // 5 minutes
allowedAttempts: 3,
alphanumeric: false,
caseInsensitive: true
},
'high_security': {
length: 8,
expirySeconds: 180, // 3 minutes
allowedAttempts: 2,
alphanumeric: true,
caseInsensitive: false
},
'transaction': {
length: 6,
expirySeconds: 120, // 2 minutes
allowedAttempts: 1, // Single attempt for transactions
alphanumeric: false,
caseInsensitive: true
}
}Rate Limiting
interface RateLimitConfig {
maxRequestsPerHour: number
maxRequestsPerDay: number
cooldownSeconds: number
blockDurationMinutes: number
}
class SMSRateLimiter {
private requestCounts: Map<string, { count: number; resetTime: number }> = new Map()
private blockedNumbers: Map<string, number> = new Map()
private config: RateLimitConfig = {
maxRequestsPerHour: 3,
maxRequestsPerDay: 10,
cooldownSeconds: 60,
blockDurationMinutes: 60
}
async canSendSMS(phoneNumber: string): Promise<{
allowed: boolean
reason?: string
retryAfter?: number
}> {
// Check if blocked
const blockExpiry = this.blockedNumbers.get(phoneNumber)
if (blockExpiry && Date.now() < blockExpiry) {
return {
allowed: false,
reason: 'Temporarily blocked due to excessive requests',
retryAfter: Math.ceil((blockExpiry - Date.now()) / 1000)
}
}
// Check hourly limit
const hourKey = `${phoneNumber}:hour:${this.getCurrentHour()}`
const hourCount = await this.getRequestCount(hourKey)
if (hourCount >= this.config.maxRequestsPerHour) {
return {
allowed: false,
reason: 'Hourly limit exceeded',
retryAfter: this.getSecondsUntilNextHour()
}
}
// Check daily limit
const dayKey = `${phoneNumber}:day:${this.getCurrentDay()}`
const dayCount = await this.getRequestCount(dayKey)
if (dayCount >= this.config.maxRequestsPerDay) {
return {
allowed: false,
reason: 'Daily limit exceeded',
retryAfter: this.getSecondsUntilNextDay()
}
}
// Check cooldown between requests
const lastRequest = await this.getLastRequestTime(phoneNumber)
if (lastRequest && Date.now() - lastRequest < this.config.cooldownSeconds * 1000) {
return {
allowed: false,
reason: 'Too many requests, please wait',
retryAfter: Math.ceil((this.config.cooldownSeconds * 1000 - (Date.now() - lastRequest)) / 1000)
}
}
return { allowed: true }
}
async recordRequest(phoneNumber: string): Promise<void> {
const hourKey = `${phoneNumber}:hour:${this.getCurrentHour()}`
const dayKey = `${phoneNumber}:day:${this.getCurrentDay()}`
await this.incrementCount(hourKey)
await this.incrementCount(dayKey)
await this.setLastRequestTime(phoneNumber, Date.now())
}
async blockNumber(phoneNumber: string): Promise<void> {
const blockUntil = Date.now() + (this.config.blockDurationMinutes * 60 * 1000)
this.blockedNumbers.set(phoneNumber, blockUntil)
}
private getCurrentHour(): string {
return new Date().toISOString().slice(0, 13)
}
private getCurrentDay(): string {
return new Date().toISOString().slice(0, 10)
}
private getSecondsUntilNextHour(): number {
const now = new Date()
const nextHour = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours() + 1, 0, 0)
return Math.ceil((nextHour.getTime() - now.getTime()) / 1000)
}
private getSecondsUntilNextDay(): number {
const now = new Date()
const nextDay = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0)
return Math.ceil((nextDay.getTime() - now.getTime()) / 1000)
}
private async getRequestCount(key: string): Promise<number> {
const data = this.requestCounts.get(key)
if (!data || Date.now() > data.resetTime) return 0
return data.count
}
private async incrementCount(key: string): Promise<void> {
const existing = this.requestCounts.get(key) || { count: 0, resetTime: Date.now() + 3600000 }
existing.count++
this.requestCounts.set(key, existing)
}
private async getLastRequestTime(phoneNumber: string): Promise<number | null> {
// Implementation would use Redis/DB
return null
}
private async setLastRequestTime(phoneNumber: string, time: number): Promise<void> {
// Implementation would use Redis/DB
}
}OTP Code Generation {#code-generation}
Secure, unpredictable code generation is critical for SMS verification.
Cryptographically Secure Generation
import crypto from 'crypto'
class OTPGenerator {
generateNumericOTP(length: number = 6): string {
// Use cryptographically secure random number generation
const buffer = crypto.randomBytes(length)
let otp = ''
for (let i = 0; i < length; i++) {
// Generate digit 0-9
otp += buffer[i] % 10
}
return otp
}
generateAlphanumericOTP(length: number = 8): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // Exclude ambiguous chars
const buffer = crypto.randomBytes(length)
let otp = ''
for (let i = 0; i < length; i++) {
otp += chars[buffer[i] % chars.length]
}
return otp
}
hashOTP(otp: string, salt: string): string {
return crypto
.createHash('sha256')
.update(otp + salt)
.digest('hex')
}
}
interface OTPRecord {
phoneNumber: string
hashedOTP: string
createdAt: Date
expiresAt: Date
attempts: number
verified: boolean
}
class OTPManager {
private otpGenerator: OTPGenerator
private otpStore: Map<string, OTPRecord> = new Map()
constructor() {
this.otpGenerator = new OTPGenerator()
}
async createOTP(phoneNumber: string, config: OTPConfig): Promise<{
otp: string
expiresIn: number
}> {
// Generate OTP
const otp = config.alphanumeric
? this.otpGenerator.generateAlphanumericOTP(config.length)
: this.otpGenerator.generateNumericOTP(config.length)
// Hash for storage (never store plaintext OTP)
const salt = crypto.randomBytes(16).toString('hex')
const hashedOTP = this.otpGenerator.hashOTP(otp, salt)
const now = new Date()
const expiresAt = new Date(now.getTime() + config.expirySeconds * 1000)
// Store hashed OTP
this.otpStore.set(phoneNumber, {
phoneNumber,
hashedOTP: `${salt}:${hashedOTP}`,
createdAt: now,
expiresAt,
attempts: 0,
verified: false
})
return {
otp, // Return plaintext only for sending
expiresIn: config.expirySeconds
}
}
async verifyOTP(phoneNumber: string, inputOTP: string, config: OTPConfig): Promise<{
valid: boolean
reason?: string
attemptsRemaining?: number
}> {
const record = this.otpStore.get(phoneNumber)
if (!record) {
return { valid: false, reason: 'No OTP found for this number' }
}
// Check expiry
if (new Date() > record.expiresAt) {
this.otpStore.delete(phoneNumber)
return { valid: false, reason: 'OTP expired' }
}
// Check attempts
if (record.attempts >= config.allowedAttempts) {
this.otpStore.delete(phoneNumber)
return { valid: false, reason: 'Maximum attempts exceeded' }
}
// Verify OTP
const [salt, storedHash] = record.hashedOTP.split(':')
const inputHash = this.otpGenerator.hashOTP(
config.caseInsensitive ? inputOTP.toUpperCase() : inputOTP,
salt
)
record.attempts++
if (inputHash === storedHash) {
record.verified = true
return { valid: true }
}
return {
valid: false,
reason: 'Invalid OTP',
attemptsRemaining: config.allowedAttempts - record.attempts
}
}
}Delivery Optimization {#delivery-optimization}
Maximizing SMS delivery success requires carrier optimization and fallback strategies.
Carrier Routing
interface SMSProvider {
name: string
priority: number
successRate: number
avgDeliveryTime: number // milliseconds
costPerSMS: number
supportedCountries: string[]
capabilities: {
unicode: boolean
longMessages: boolean
deliveryReceipts: boolean
}
}
const SMS_PROVIDERS: SMSProvider[] = [
{
name: 'Twilio',
priority: 1,
successRate: 0.98,
avgDeliveryTime: 2000,
costPerSMS: 0.0075,
supportedCountries: ['US', 'CA', 'UK', 'DE', 'FR'],
capabilities: { unicode: true, longMessages: true, deliveryReceipts: true }
},
{
name: 'MessageBird',
priority: 2,
successRate: 0.96,
avgDeliveryTime: 2500,
costPerSMS: 0.006,
supportedCountries: ['US', 'UK', 'NL', 'BE'],
capabilities: { unicode: true, longMessages: true, deliveryReceipts: true }
},
{
name: 'Plivo',
priority: 3,
successRate: 0.95,
avgDeliveryTime: 3000,
costPerSMS: 0.005,
supportedCountries: ['US', 'IN', 'UK'],
capabilities: { unicode: true, longMessages: false, deliveryReceipts: true }
}
]
class SMSRouter {
selectProvider(phoneNumber: string, country: string): SMSProvider {
// Filter providers supporting the country
const availableProviders = SMS_PROVIDERS.filter(p =>
p.supportedCountries.includes(country)
)
if (availableProviders.length === 0) {
// Fallback to first provider
return SMS_PROVIDERS[0]
}
// Select based on success rate and cost
const sorted = availableProviders.sort((a, b) => {
const scoreA = a.successRate * 100 - a.costPerSMS * 1000
const scoreB = b.successRate * 100 - b.costPerSMS * 1000
return scoreB - scoreA
})
return sorted[0]
}
async sendWithFallback(
phoneNumber: string,
message: string,
country: string
): Promise<{
success: boolean
provider: string
deliveryTime?: number
error?: string
}> {
const providers = SMS_PROVIDERS
.filter(p => p.supportedCountries.includes(country))
.sort((a, b) => a.priority - b.priority)
for (const provider of providers) {
try {
const startTime = Date.now()
await this.sendSMS(provider, phoneNumber, message)
const deliveryTime = Date.now() - startTime
return {
success: true,
provider: provider.name,
deliveryTime
}
} catch (error) {
console.error(`Failed to send via ${provider.name}:`, error)
// Try next provider
continue
}
}
return {
success: false,
provider: 'none',
error: 'All providers failed'
}
}
private async sendSMS(provider: SMSProvider, phoneNumber: string, message: string): Promise<void> {
// Implementation would call actual provider API
console.log(`Sending SMS via ${provider.name} to ${phoneNumber}`)
}
}Message Optimization
Best Practices:
- Keep messages under 160 characters to avoid split messages
- Include brand name for recognition
- Specify OTP expiry time
- Avoid URLs (often flagged as spam)
- Use alphanumeric sender ID where supported
interface MessageTemplate {
pattern: string
maxLength: number
includeExpiry: boolean
}
const MESSAGE_TEMPLATES: Record<string, MessageTemplate> = {
'standard': {
pattern: '{code} is your {brand} verification code. Valid for {expiry} minutes.',
maxLength: 160,
includeExpiry: true
},
'short': {
pattern: '{code} is your {brand} code',
maxLength: 160,
includeExpiry: false
},
'transaction': {
pattern: '{code} - Confirm your {brand} transaction. Expires in {expiry}min. Never share this code.',
maxLength: 160,
includeExpiry: true
}
}
class MessageComposer {
composeMessage(
otp: string,
brand: string,
expiryMinutes: number,
template: string = 'standard'
): string {
const config = MESSAGE_TEMPLATES[template]
let message = config.pattern
.replace('{code}', otp)
.replace('{brand}', brand)
if (config.includeExpiry) {
message = message.replace('{expiry}', expiryMinutes.toString())
}
// Ensure message fits in single SMS
if (message.length > config.maxLength) {
console.warn('Message exceeds max length, using short template')
return this.composeMessage(otp, brand, expiryMinutes, 'short')
}
return message
}
}Fraud Prevention {#fraud-prevention}
SMS fraud includes SMS pumping, toll fraud, and automated abuse.
SMS Pumping Detection
interface PumpingIndicators {
rapidRegistrations: boolean
sequentialNumbers: boolean
highRiskCountry: boolean
lowEngagement: boolean
premiumNumbers: boolean
}
class SMSPumpingDetector {
private suspiciousCountries = ['SS', 'TD', 'CU', 'KP'] // High-risk countries
private premiumPrefixes = ['+1900', '+1911'] // Premium rate prefixes
detectPumping(phoneNumber: string, context: {
registrationVelocity: number
recentNumbers: string[]
countryCode: string
userEngagement: number
}): {
isPumping: boolean
confidence: number
indicators: PumpingIndicators
recommendation: 'allow' | 'challenge' | 'block'
} {
const indicators: PumpingIndicators = {
rapidRegistrations: context.registrationVelocity > 10, // >10 registrations/min
sequentialNumbers: this.checkSequentialPattern(context.recentNumbers),
highRiskCountry: this.suspiciousCountries.includes(context.countryCode),
lowEngagement: context.userEngagement < 0.1, // <10% engagement
premiumNumbers: this.isPremiumNumber(phoneNumber)
}
// Calculate confidence score
let confidence = 0
if (indicators.rapidRegistrations) confidence += 30
if (indicators.sequentialNumbers) confidence += 25
if (indicators.highRiskCountry) confidence += 20
if (indicators.lowEngagement) confidence += 15
if (indicators.premiumNumbers) confidence += 10
// Determine recommendation
let recommendation: 'allow' | 'challenge' | 'block'
if (confidence >= 70) recommendation = 'block'
else if (confidence >= 40) recommendation = 'challenge'
else recommendation = 'allow'
return {
isPumping: confidence >= 40,
confidence,
indicators,
recommendation
}
}
private checkSequentialPattern(numbers: string[]): boolean {
if (numbers.length < 3) return false
const lastDigits = numbers.map(n => parseInt(n.slice(-4)))
const sorted = [...lastDigits].sort((a, b) => a - b)
return sorted.every((digit, index) =>
index === 0 || digit === sorted[index - 1] + 1
)
}
private isPremiumNumber(phoneNumber: string): boolean {
return this.premiumPrefixes.some(prefix => phoneNumber.startsWith(prefix))
}
}Bot Detection
interface BotDetectionSignals {
suspiciousUserAgent: boolean
rapidRequests: boolean
missingJavaScript: boolean
automatedPattern: boolean
captchaFailed: boolean
}
class SMSBotDetector {
detectBot(request: {
userAgent: string
requestTimings: number[]
hasJavaScript: boolean
mouseMovements: number
captchaScore?: number
}): {
isBot: boolean
confidence: number
signals: BotDetectionSignals
} {
const signals: BotDetectionSignals = {
suspiciousUserAgent: this.checkUserAgent(request.userAgent),
rapidRequests: this.checkRequestTiming(request.requestTimings),
missingJavaScript: !request.hasJavaScript,
automatedPattern: request.mouseMovements === 0,
captchaFailed: (request.captchaScore || 1) < 0.5
}
let confidence = 0
if (signals.suspiciousUserAgent) confidence += 25
if (signals.rapidRequests) confidence += 20
if (signals.missingJavaScript) confidence += 15
if (signals.automatedPattern) confidence += 20
if (signals.captchaFailed) confidence += 20
return {
isBot: confidence >= 50,
confidence,
signals
}
}
private checkUserAgent(userAgent: string): boolean {
const botPatterns = ['bot', 'crawler', 'spider', 'headless']
return botPatterns.some(pattern =>
userAgent.toLowerCase().includes(pattern)
)
}
private checkRequestTiming(timings: number[]): boolean {
if (timings.length < 3) return false
// Check if requests are too uniform (typical of bots)
const avg = timings.reduce((sum, t) => sum + t, 0) / timings.length
const variance = timings.reduce((sum, t) => sum + Math.pow(t - avg, 2), 0) / timings.length
return variance < 100 // Very low variance indicates automation
}
}User Experience {#user-experience}
Excellent UX balances security with ease of use.
Auto-Detection and Autofill
// Support SMS autofill on web
const otpInput = document.querySelector('input[autocomplete="one-time-code"]')
// iOS: Use SMS verification code autofill
// Android: Use SMS Retriever API
// Example SMS format for autofill:
// "123456 is your Brand verification code.
// @yourdomain.com #123456"Fallback Options
interface VerificationOptions {
primary: 'sms'
fallbacks: ('voice' | 'email' | 'authenticator')[]
allowSkip: boolean
}
class VerificationFlow {
async initiateVerification(
phoneNumber: string,
options: VerificationOptions
): Promise<{
method: string
deliveryStatus: string
fallbacksAvailable: string[]
}> {
// Try primary method (SMS)
const smsResult = await this.sendSMS(phoneNumber)
if (smsResult.success) {
return {
method: 'sms',
deliveryStatus: 'sent',
fallbacksAvailable: options.fallbacks
}
}
// Offer fallbacks if SMS fails
return {
method: 'sms',
deliveryStatus: 'failed',
fallbacksAvailable: options.fallbacks
}
}
private async sendSMS(phoneNumber: string): Promise<{ success: boolean }> {
// Implementation
return { success: true }
}
}Monitoring and Analytics {#monitoring}
Track delivery, security, and fraud metrics.
Key Metrics
Delivery Metrics:
- Delivery success rate by provider/country
- Average delivery time
- Failed delivery reasons
- Retry success rate
Security Metrics:
- Verification success rate
- Average attempts per verification
- Expired OTP percentage
- Rate limit violations
Fraud Metrics:
- SMS pumping attempts blocked
- Bot detection accuracy
- High-risk country traffic
- Cost per verification by region
interface VerificationMetrics {
totalRequests: number
successfulDeliveries: number
failedDeliveries: number
successfulVerifications: number
failedVerifications: number
averageDeliveryTime: number
averageAttempts: number
blockRatePumping: number
blockRateBots: number
}
class MetricsCollector {
collectMetrics(period: { start: Date; end: Date }): VerificationMetrics {
// Implementation would query from database/analytics
return {
totalRequests: 10000,
successfulDeliveries: 9800,
failedDeliveries: 200,
successfulVerifications: 9500,
failedVerifications: 300,
averageDeliveryTime: 2500,
averageAttempts: 1.2,
blockRatePumping: 150,
blockRateBots: 80
}
}
calculateKPIs(metrics: VerificationMetrics): {
deliveryRate: number
verificationRate: number
costEfficiency: number
} {
return {
deliveryRate: (metrics.successfulDeliveries / metrics.totalRequests) * 100,
verificationRate: (metrics.successfulVerifications / metrics.successfulDeliveries) * 100,
costEfficiency: metrics.successfulVerifications / metrics.totalRequests
}
}
}Conclusion {#conclusion}
Secure SMS verification requires strong OTP generation, rate limiting, delivery optimization, fraud prevention, and excellent user experience. Success depends on cryptographically secure codes, intelligent carrier routing, comprehensive fraud detection, and continuous monitoring.
Key success factors include implementing proper rate limits, using multiple SMS providers with fallback, detecting and blocking SMS pumping, supporting autofill for better UX, and tracking comprehensive metrics.
Implement secure, user-friendly SMS verification with our phone validation APIs, designed to prevent fraud while maintaining excellent delivery rates and user experience.