SMS Verification & OTP

Implement SMS-based verification and one-time passwords with Transactional.

SMS Verification & OTP

This guide walks you through implementing SMS verification (OTP) for user authentication, account verification, and two-factor authentication.

Overview

SMS verification typically involves:

  1. User requests verification (signup, login, password reset)
  2. Generate and store a verification code
  3. Send the code via SMS
  4. User enters the code
  5. Verify the code and complete the action

Prerequisites

  • SMS enabled on your Transactional account
  • An approved SMS template for OTP messages
  • A phone number pool configured

Step 1: Create an OTP Template

First, create an SMS template in your dashboard with the following content:

Your verification code is {{code}}. This code expires in 10 minutes.

Save it with the alias otp-verification.

Step 2: Generate and Send OTP

import { Transactional } from 'transactional-sdk';
import crypto from 'crypto';
 
const client = new Transactional({
  apiKey: process.env.TRANSACTIONAL_API_KEY,
});
 
// In-memory store (use Redis in production)
const otpStore = new Map<string, { code: string; expires: Date }>();
 
async function sendVerificationCode(phoneNumber: string): Promise<void> {
  // Generate 6-digit code
  const code = crypto.randomInt(100000, 999999).toString();
 
  // Store code with 10-minute expiry
  const expires = new Date(Date.now() + 10 * 60 * 1000);
  otpStore.set(phoneNumber, { code, expires });
 
  // Send SMS
  await client.sms.send({
    to: phoneNumber,
    templateAlias: 'otp-verification',
    templateModel: { code },
    tag: 'otp',
    metadata: { type: 'verification' },
  });
}

Step 3: Verify the Code

interface VerificationResult {
  success: boolean;
  error?: string;
}
 
function verifyCode(phoneNumber: string, inputCode: string): VerificationResult {
  const stored = otpStore.get(phoneNumber);
 
  if (!stored) {
    return { success: false, error: 'No verification code found' };
  }
 
  if (new Date() > stored.expires) {
    otpStore.delete(phoneNumber);
    return { success: false, error: 'Code expired' };
  }
 
  if (stored.code !== inputCode) {
    return { success: false, error: 'Invalid code' };
  }
 
  // Code is valid - clean up
  otpStore.delete(phoneNumber);
  return { success: true };
}

Complete Example: Express.js API

import express from 'express';
import { Transactional } from 'transactional-sdk';
import Redis from 'ioredis';
 
const app = express();
const redis = new Redis(process.env.REDIS_URL);
const client = new Transactional({
  apiKey: process.env.TRANSACTIONAL_API_KEY,
});
 
// Request verification code
app.post('/api/verify/request', async (req, res) => {
  const { phoneNumber } = req.body;
 
  // Validate phone number format
  if (!phoneNumber.match(/^\+[1-9]\d{1,14}$/)) {
    return res.status(400).json({ error: 'Invalid phone number format' });
  }
 
  // Rate limit: 1 request per minute
  const rateLimitKey = `otp:ratelimit:${phoneNumber}`;
  const exists = await redis.exists(rateLimitKey);
  if (exists) {
    return res.status(429).json({ error: 'Please wait before requesting a new code' });
  }
 
  // Generate and store code
  const code = Math.floor(100000 + Math.random() * 900000).toString();
  const codeKey = `otp:code:${phoneNumber}`;
 
  await redis.setex(codeKey, 600, code); // 10 minute expiry
  await redis.setex(rateLimitKey, 60, '1'); // 1 minute rate limit
 
  // Send SMS
  try {
    await client.sms.send({
      to: phoneNumber,
      templateAlias: 'otp-verification',
      templateModel: { code },
      tag: 'otp',
    });
 
    res.json({ success: true, message: 'Verification code sent' });
  } catch (error) {
    console.error('SMS send error:', error);
    res.status(500).json({ error: 'Failed to send verification code' });
  }
});
 
// Verify code
app.post('/api/verify/confirm', async (req, res) => {
  const { phoneNumber, code } = req.body;
 
  const codeKey = `otp:code:${phoneNumber}`;
  const storedCode = await redis.get(codeKey);
 
  if (!storedCode) {
    return res.status(400).json({ error: 'No verification code found or code expired' });
  }
 
  if (storedCode !== code) {
    // Track failed attempts
    const attemptsKey = `otp:attempts:${phoneNumber}`;
    const attempts = await redis.incr(attemptsKey);
    await redis.expire(attemptsKey, 600);
 
    if (attempts >= 5) {
      await redis.del(codeKey);
      return res.status(400).json({ error: 'Too many failed attempts. Request a new code.' });
    }
 
    return res.status(400).json({ error: 'Invalid code' });
  }
 
  // Success - clean up
  await redis.del(codeKey);
  await redis.del(`otp:attempts:${phoneNumber}`);
 
  // Generate session token, mark user as verified, etc.
  res.json({ success: true, verified: true });
});
 
app.listen(3000);

Two-Factor Authentication (2FA)

For 2FA, add an additional verification step after password login:

import { authenticator } from 'otplib';
 
// Generate a TOTP-compatible code
function generateTOTPCode(): string {
  const secret = authenticator.generateSecret();
  return authenticator.generate(secret);
}
 
// 2FA login flow
app.post('/api/auth/login', async (req, res) => {
  const { email, password } = req.body;
 
  // Verify password (implementation depends on your auth system)
  const user = await verifyPassword(email, password);
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
 
  // Check if 2FA is enabled
  if (user.twoFactorEnabled && user.phoneNumber) {
    const code = Math.floor(100000 + Math.random() * 900000).toString();
 
    // Store 2FA session
    const sessionId = crypto.randomUUID();
    await redis.setex(`2fa:session:${sessionId}`, 300, JSON.stringify({
      userId: user.id,
      code,
    }));
 
    // Send SMS
    await client.sms.send({
      to: user.phoneNumber,
      templateAlias: '2fa-code',
      templateModel: { code },
      tag: '2fa',
    });
 
    return res.json({
      requiresTwoFactor: true,
      sessionId,
    });
  }
 
  // No 2FA - return token
  const token = generateAuthToken(user);
  res.json({ token });
});
 
// Verify 2FA code
app.post('/api/auth/verify-2fa', async (req, res) => {
  const { sessionId, code } = req.body;
 
  const sessionData = await redis.get(`2fa:session:${sessionId}`);
  if (!sessionData) {
    return res.status(400).json({ error: 'Session expired' });
  }
 
  const session = JSON.parse(sessionData);
  if (session.code !== code) {
    return res.status(400).json({ error: 'Invalid code' });
  }
 
  // Clean up and generate token
  await redis.del(`2fa:session:${sessionId}`);
  const user = await getUserById(session.userId);
  const token = generateAuthToken(user);
 
  res.json({ token });
});

Best Practices

Security

  1. Rate limiting: Prevent abuse by limiting code requests
  2. Attempt limiting: Lock out after failed attempts
  3. Code expiry: Short expiration (5-10 minutes)
  4. Secure storage: Use Redis or similar with encryption

User Experience

  1. Resend option: Allow users to request a new code after 60 seconds
  2. Clear messaging: Tell users when codes expire
  3. Auto-submit: Auto-verify when all digits are entered

Code Format

// 6 digits is standard and easy to type
const code = Math.floor(100000 + Math.random() * 900000).toString();
 
// For alphanumeric codes (harder to guess)
function generateAlphanumericCode(length: number = 6): string {
  const chars = '0123456789ABCDEFGHJKLMNPQRSTUVWXYZ'; // Removed confusing chars
  let code = '';
  for (let i = 0; i < length; i++) {
    code += chars[Math.floor(Math.random() * chars.length)];
  }
  return code;
}

Handling Errors

try {
  await client.sms.send({
    to: phoneNumber,
    templateAlias: 'otp-verification',
    templateModel: { code },
  });
} catch (error) {
  if (error.code === 'RECIPIENT_SUPPRESSED') {
    // User has opted out
    throw new Error('This phone number has opted out of SMS');
  }
  if (error.code === 'INVALID_PHONE_NUMBER') {
    throw new Error('Invalid phone number format');
  }
  if (error.code === 'NO_POOL_NUMBER_AVAILABLE') {
    // No number available for this country
    throw new Error('SMS not available for this region');
  }
  throw error;
}

Next Steps