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:
- User requests verification (signup, login, password reset)
- Generate and store a verification code
- Send the code via SMS
- User enters the code
- 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
- Rate limiting: Prevent abuse by limiting code requests
- Attempt limiting: Lock out after failed attempts
- Code expiry: Short expiration (5-10 minutes)
- Secure storage: Use Redis or similar with encryption
User Experience
- Resend option: Allow users to request a new code after 60 seconds
- Clear messaging: Tell users when codes expire
- 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
- SMS API Reference - Complete API documentation
- SMS Templates - Creating and managing templates
- SMS Webhooks - Delivery notifications