Terminal.skills
Use Cases/Build SMS OTP Verification

Build SMS OTP Verification

Build a secure SMS OTP verification system with rate limiting, delivery tracking, fallback channels, brute-force protection, and cost optimization through smart retry logic.

#redis#caching#database#pub-sub#queues
Works with:claude-codeopenai-codexgemini-clicursor
$

The Problem

Marco leads engineering at a 20-person fintech app. They use email-based verification, but completion rates are 62% — users check email later or never. Fraud is increasing because email verification is easy to bypass. They want SMS OTP for critical actions (login, payment confirmation, account changes), but SMS costs $0.01-0.08 per message. Without rate limiting, a single attacker could cost them thousands. They also need fallback when SMS fails and protection against OTP brute-forcing.

Step 1: Build the OTP System

typescript
// src/auth/otp.ts — SMS OTP with rate limiting, brute-force protection, and fallback
import { pool } from "../db";
import { Redis } from "ioredis";
import { createHash, randomInt } from "node:crypto";

const redis = new Redis(process.env.REDIS_URL!);

interface OTPConfig {
  length: number;              // 4 or 6 digits
  expirySeconds: number;
  maxAttempts: number;         // max verification tries before lockout
  cooldownSeconds: number;     // minimum time between sends
  maxSendsPerHour: number;
  maxSendsPerDay: number;
}

const CONFIGS: Record<string, OTPConfig> = {
  login: { length: 6, expirySeconds: 300, maxAttempts: 3, cooldownSeconds: 60, maxSendsPerHour: 5, maxSendsPerDay: 10 },
  payment: { length: 6, expirySeconds: 180, maxAttempts: 3, cooldownSeconds: 30, maxSendsPerHour: 10, maxSendsPerDay: 20 },
  account_change: { length: 6, expirySeconds: 600, maxAttempts: 5, cooldownSeconds: 60, maxSendsPerHour: 5, maxSendsPerDay: 10 },
};

interface SendResult {
  success: boolean;
  error?: string;
  retryAfter?: number;         // seconds until can resend
  channel: "sms" | "whatsapp" | "email";  // actual delivery channel
  expiresIn: number;
}

// Send OTP
export async function sendOTP(
  phone: string,
  purpose: string,
  options?: { preferredChannel?: "sms" | "whatsapp" }
): Promise<SendResult> {
  const config = CONFIGS[purpose] || CONFIGS.login;
  const normalizedPhone = normalizePhone(phone);
  const rateLimitKey = `otp:rate:${normalizedPhone}`;

  // Check cooldown
  const lastSent = await redis.get(`otp:cooldown:${normalizedPhone}`);
  if (lastSent) {
    const elapsed = Date.now() - parseInt(lastSent);
    const remaining = Math.ceil((config.cooldownSeconds * 1000 - elapsed) / 1000);
    if (remaining > 0) {
      return { success: false, error: "Please wait before requesting another code", retryAfter: remaining, channel: "sms", expiresIn: 0 };
    }
  }

  // Check hourly/daily limits
  const hourKey = `otp:hourly:${normalizedPhone}:${Math.floor(Date.now() / 3600000)}`;
  const dayKey = `otp:daily:${normalizedPhone}:${new Date().toISOString().slice(0, 10)}`;

  const [hourCount, dayCount] = await Promise.all([
    redis.incr(hourKey).then(async (v) => { await redis.expire(hourKey, 3600); return v; }),
    redis.incr(dayKey).then(async (v) => { await redis.expire(dayKey, 86400); return v; }),
  ]);

  if (hourCount > config.maxSendsPerHour) {
    return { success: false, error: "Too many requests. Try again in an hour.", channel: "sms", expiresIn: 0 };
  }
  if (dayCount > config.maxSendsPerDay) {
    return { success: false, error: "Daily limit reached. Try again tomorrow.", channel: "sms", expiresIn: 0 };
  }

  // Generate OTP
  const code = generateOTP(config.length);
  const hashedCode = hashOTP(code, normalizedPhone);

  // Store OTP
  const otpKey = `otp:code:${normalizedPhone}:${purpose}`;
  await redis.setex(otpKey, config.expirySeconds, JSON.stringify({
    hash: hashedCode,
    attempts: 0,
    maxAttempts: config.maxAttempts,
    createdAt: Date.now(),
  }));

  // Set cooldown
  await redis.setex(`otp:cooldown:${normalizedPhone}`, config.cooldownSeconds, String(Date.now()));

  // Send via preferred channel with fallback
  let channel: "sms" | "whatsapp" | "email" = options?.preferredChannel || "sms";
  let delivered = false;

  // Try SMS first
  if (channel === "sms") {
    delivered = await sendSMS(normalizedPhone, `Your verification code is: ${code}. Expires in ${Math.floor(config.expirySeconds / 60)} min.`);
  }

  // Fallback to WhatsApp if SMS fails
  if (!delivered && channel === "sms") {
    channel = "whatsapp";
    delivered = await sendWhatsApp(normalizedPhone, code, config.expirySeconds);
  }

  // Last resort: email fallback
  if (!delivered) {
    channel = "email";
    const { rows: [user] } = await pool.query("SELECT email FROM users WHERE phone = $1", [normalizedPhone]);
    if (user?.email) {
      delivered = await sendEmailOTP(user.email, code, purpose, config.expirySeconds);
    }
  }

  if (!delivered) {
    return { success: false, error: "Could not deliver verification code. Please try again.", channel, expiresIn: 0 };
  }

  // Log for analytics
  await pool.query(
    `INSERT INTO otp_sends (phone_hash, purpose, channel, created_at) VALUES ($1, $2, $3, NOW())`,
    [createHash("sha256").update(normalizedPhone).digest("hex").slice(0, 16), purpose, channel]
  );

  return { success: true, channel, expiresIn: config.expirySeconds };
}

// Verify OTP
export async function verifyOTP(
  phone: string,
  purpose: string,
  code: string
): Promise<{ valid: boolean; error?: string; remainingAttempts?: number }> {
  const normalizedPhone = normalizePhone(phone);
  const otpKey = `otp:code:${normalizedPhone}:${purpose}`;

  // Check lockout
  const lockoutKey = `otp:lockout:${normalizedPhone}`;
  const locked = await redis.get(lockoutKey);
  if (locked) {
    const ttl = await redis.ttl(lockoutKey);
    return { valid: false, error: `Account locked. Try again in ${ttl} seconds.` };
  }

  const stored = await redis.get(otpKey);
  if (!stored) {
    return { valid: false, error: "Code expired or not found. Request a new one." };
  }

  const data = JSON.parse(stored);

  // Check attempts
  if (data.attempts >= data.maxAttempts) {
    await redis.del(otpKey);
    await redis.setex(lockoutKey, 900, "1"); // 15-minute lockout
    return { valid: false, error: "Too many failed attempts. Account locked for 15 minutes." };
  }

  // Verify hash
  const inputHash = hashOTP(code, normalizedPhone);
  if (inputHash !== data.hash) {
    data.attempts++;
    await redis.setex(otpKey, await redis.ttl(otpKey), JSON.stringify(data));
    return {
      valid: false,
      error: "Invalid code",
      remainingAttempts: data.maxAttempts - data.attempts,
    };
  }

  // Valid — clean up
  await redis.del(otpKey);
  await redis.del(`otp:cooldown:${normalizedPhone}`);

  // Log verification
  await pool.query(
    `INSERT INTO otp_verifications (phone_hash, purpose, attempts_used, verified_at) VALUES ($1, $2, $3, NOW())`,
    [createHash("sha256").update(normalizedPhone).digest("hex").slice(0, 16), purpose, data.attempts + 1]
  );

  return { valid: true };
}

// Generate cryptographically secure OTP
function generateOTP(length: number): string {
  const max = Math.pow(10, length);
  const min = Math.pow(10, length - 1);
  return String(randomInt(min, max));
}

// Hash OTP with phone as salt (prevents rainbow table attacks)
function hashOTP(code: string, phone: string): string {
  return createHash("sha256").update(`${code}:${phone}:${process.env.OTP_SECRET}`).digest("hex");
}

function normalizePhone(phone: string): string {
  return phone.replace(/[\s\-\(\)]/g, "").replace(/^00/, "+");
}

async function sendSMS(phone: string, message: string): Promise<boolean> {
  try {
    const res = await fetch("https://api.twilio.com/2010-04-01/Accounts/" + process.env.TWILIO_SID + "/Messages.json", {
      method: "POST",
      headers: {
        "Authorization": "Basic " + Buffer.from(`${process.env.TWILIO_SID}:${process.env.TWILIO_TOKEN}`).toString("base64"),
        "Content-Type": "application/x-www-form-urlencoded",
      },
      body: new URLSearchParams({ To: phone, From: process.env.TWILIO_FROM!, Body: message }),
    });
    return res.ok;
  } catch { return false; }
}

async function sendWhatsApp(phone: string, code: string, expirySeconds: number): Promise<boolean> {
  // WhatsApp Business API fallback
  try {
    const res = await fetch(`https://graph.facebook.com/v18.0/${process.env.WHATSAPP_PHONE_ID}/messages`, {
      method: "POST",
      headers: { "Authorization": `Bearer ${process.env.WHATSAPP_TOKEN}`, "Content-Type": "application/json" },
      body: JSON.stringify({
        messaging_product: "whatsapp", to: phone,
        type: "template", template: {
          name: "otp_verification", language: { code: "en" },
          components: [{ type: "body", parameters: [{ type: "text", text: code }] }],
        },
      }),
    });
    return res.ok;
  } catch { return false; }
}

async function sendEmailOTP(email: string, code: string, purpose: string, expirySeconds: number): Promise<boolean> {
  await redis.rpush("email:send:queue", JSON.stringify({
    to: email, subject: `Your verification code: ${code}`,
    template: "otp_email", data: { code, purpose, expiresIn: Math.floor(expirySeconds / 60) },
  }));
  return true;
}

Results

  • Verification completion: 62% → 94% — SMS arrives in seconds; users verify without leaving the app; no more "check your email later"
  • Brute-force attacks neutralized — 3 wrong attempts = 15-minute lockout; hashed OTPs in Redis; no plaintext codes stored
  • SMS costs controlled — rate limiting caps at 10/day per number; an attacker can't trigger thousands of messages; monthly SMS cost stays under $200
  • Fallback prevents delivery failures — SMS fails → WhatsApp → email; 99.7% delivery rate across all channels; users always get their code
  • Fraud reduced 80% — payment confirmation requires OTP; stolen credentials alone aren't enough; chargebacks from account takeover dropped to near zero