OTP & 2FA via SMS
Overview
One-time passwords (OTP) sent via SMS are the most widely adopted method for two-factor authentication (2FA). Unlike email-based codes, SMS messages land on a device your user already has in their hand, delivering near-instant verification with open rates above 95%.
DIDfarm's SMS API lets you generate, deliver, and verify OTP codes in any of the 70+ countries where we provide numbers. This guide walks through the full integration, from generating a secure code to handling edge cases like expired tokens and delivery failures.
Prerequisites
- A DIDfarm account with at least one SMS-enabled number. You can purchase numbers at /buy.
- An API key generated from the API Keys tab in your portal.
- A server-side application capable of making HTTPS requests (Node.js, Python, PHP, etc.).
- A database or in-memory store (Redis, Memcached) for persisting codes until verification.
How OTP Works
The OTP verification flow follows four steps:
| Step | Action | Where |
|---|---|---|
| 1 | Generate a cryptographically random 6-digit code and store it with an expiry timestamp | Your server |
| 2 | Send the code to the user's phone number via POST /api/v1/sms/messages |
DIDfarm API |
| 3 | User receives the SMS and enters the code into your application | User's device |
| 4 | Your server compares the submitted code against the stored code and checks expiry | Your server |
Generate & Send OTP
Generate a secure code
Use a cryptographically secure random number generator. Avoid Math.random() or similar non-secure functions.
const crypto = require('crypto');
function generateOTP(length = 6) {
const max = Math.pow(10, length);
const code = crypto.randomInt(0, max);
return code.toString().padStart(length, '0');
}
// Generate and store
const otp = generateOTP();
const expiresAt = Date.now() + 5 * 60 * 1000; // 5 minutes
// Store in Redis (recommended) or your database
await redis.setex(`otp:${phoneNumber}`, 300, JSON.stringify({
code: otp,
attempts: 0,
createdAt: Date.now()
}));import secrets
import time
import redis
def generate_otp(length=6):
return ''.join([str(secrets.randbelow(10)) for _ in range(length)])
otp = generate_otp()
expires_at = int(time.time()) + 300 # 5 minutes
# Store in Redis
r = redis.Redis()
r.setex(
f"otp:{phone_number}",
300,
json.dumps({"code": otp, "attempts": 0, "created_at": int(time.time())})
)Send the OTP via DIDfarm API
Call the POST /api/v1/sms/messages endpoint with your OTP code embedded in the message body.
const axios = require('axios');
async function sendOTP(to, code) {
const response = await axios.post(
'https://didfarm.com/api/v1/sms/messages',
{
from: '+31201234567', // Your DIDfarm SMS-enabled number
to: to, // Recipient in E.164 format
body: `Your verification code is ${code}. It expires in 5 minutes.`,
webhook_url: 'https://yourapp.com/webhooks/sms-status'
},
{
headers: {
'Authorization': `Bearer ${process.env.DIDFARM_API_KEY}`,
'Content-Type': 'application/json'
}
}
);
return response.data;
}
// Usage
const result = await sendOTP('+4915112345678', otp);
console.log('Message ID:', result.data.message_id);import requests
import os
def send_otp(to: str, code: str) -> dict:
response = requests.post(
"https://didfarm.com/api/v1/sms/messages",
json={
"from": "+31201234567",
"to": to,
"body": f"Your verification code is {code}. It expires in 5 minutes.",
"webhook_url": "https://yourapp.com/webhooks/sms-status"
},
headers={
"Authorization": f"Bearer {os.environ['DIDFARM_API_KEY']}",
"Content-Type": "application/json"
}
)
response.raise_for_status()
return response.json()
# Usage
result = send_otp("+4915112345678", otp)
print("Message ID:", result["data"]["message_id"])API Response
{
"data": {
"message_id": "msg_8f3a2b1c4d5e6f7a",
"from": "+31201234567",
"to": "+4915112345678",
"status": "queued",
"segments": 1,
"created_at": "2026-04-05T10:30:00Z"
}
}Verify the Code
Compare the user's input against the stored code
When the user submits their code, retrieve the stored record, check expiry, and compare using a timing-safe comparison function to prevent timing attacks.
const crypto = require('crypto');
async function verifyOTP(phoneNumber, submittedCode) {
const record = await redis.get(`otp:${phoneNumber}`);
if (!record) {
return { valid: false, error: 'Code expired or not found' };
}
const data = JSON.parse(record);
// Check max attempts (prevent brute force)
if (data.attempts >= 5) {
await redis.del(`otp:${phoneNumber}`);
return { valid: false, error: 'Too many attempts. Request a new code.' };
}
// Increment attempt counter
data.attempts += 1;
await redis.setex(`otp:${phoneNumber}`, 300, JSON.stringify(data));
// Timing-safe comparison
const valid = crypto.timingSafeEqual(
Buffer.from(submittedCode),
Buffer.from(data.code)
);
if (valid) {
await redis.del(`otp:${phoneNumber}`); // One-time use
return { valid: true };
}
return { valid: false, error: 'Invalid code' };
}import hmac
def verify_otp(phone_number: str, submitted_code: str) -> dict:
record = r.get(f"otp:{phone_number}")
if not record:
return {"valid": False, "error": "Code expired or not found"}
data = json.loads(record)
if data["attempts"] >= 5:
r.delete(f"otp:{phone_number}")
return {"valid": False, "error": "Too many attempts. Request a new code."}
data["attempts"] += 1
r.setex(f"otp:{phone_number}", 300, json.dumps(data))
# Timing-safe comparison
if hmac.compare_digest(submitted_code, data["code"]):
r.delete(f"otp:{phone_number}")
return {"valid": True}
return {"valid": False, "error": "Invalid code"}Delivery Status Webhook
DIDfarm sends delivery status updates to the webhook_url you specified when sending the message. Use this to detect failed deliveries and trigger a fallback (e.g., voice call or WhatsApp).
{
"event": "message.status_update",
"data": {
"message_id": "msg_8f3a2b1c4d5e6f7a",
"to": "+4915112345678",
"status": "delivered",
"delivered_at": "2026-04-05T10:30:03Z",
"error_code": null
}
}{
"event": "message.status_update",
"data": {
"message_id": "msg_8f3a2b1c4d5e6f7a",
"to": "+4915112345678",
"status": "failed",
"error_code": "30003",
"error_message": "Unreachable destination handset"
}
}WhatsApp Fallback
For users on WhatsApp, you can send the OTP through WhatsApp first and fall back to SMS if the message is not delivered within 10 seconds. This reduces SMS costs and improves the user experience in markets where WhatsApp dominates (Western Europe, Latin America, India).
async function sendOTPWithFallback(to, code) {
// Try WhatsApp first
const whatsapp = await axios.post(
'https://didfarm.com/api/v1/whatsapp/messages',
{
from: '+31201234567',
to: to,
template: 'otp_verification',
variables: { code: code, expiry_minutes: '5' }
},
{ headers: { 'Authorization': `Bearer ${process.env.DIDFARM_API_KEY}` } }
);
// Wait 10 seconds for delivery confirmation
const delivered = await waitForDelivery(whatsapp.data.data.message_id, 10000);
if (!delivered) {
// Fall back to SMS
console.log('WhatsApp not delivered, falling back to SMS');
return sendOTP(to, code);
}
return whatsapp.data;
}Branded Sender ID
In many European countries, you can replace the sending phone number with an alphanumeric sender ID (up to 11 characters). This displays your brand name instead of a phone number, increasing trust and open rates.
await axios.post('https://didfarm.com/api/v1/sms/messages', {
from: 'YourBrand', // Alphanumeric sender ID (max 11 chars)
to: '+4915112345678',
body: 'Your verification code is 847293. It expires in 5 minutes.',
webhook_url: 'https://yourapp.com/webhooks/sms-status'
}, {
headers: { 'Authorization': `Bearer ${process.env.DIDFARM_API_KEY}` }
});| Country | Sender ID Support | Notes |
|---|---|---|
| Netherlands | Supported | No pre-registration required |
| Germany | Supported | No pre-registration required |
| United Kingdom | Supported | No pre-registration required |
| France | Restricted | Must use a numeric sender for transactional SMS |
| Belgium | Supported | Pre-registration recommended |
| United States | Not supported | Use a local or toll-free number instead |
Rate Limiting
To prevent abuse and protect your account from fraudulent OTP pumping, enforce rate limits on both your application side and rely on DIDfarm's built-in protections.
| Limit | Value | Scope |
|---|---|---|
| Minimum interval between OTPs | 60 seconds |
Per recipient number |
| Maximum OTPs per hour | 5 |
Per recipient number |
| Maximum OTPs per day | 20 |
Per recipient number |
| Maximum verification attempts | 5 |
Per code |
async function checkRateLimit(phoneNumber) {
const hourKey = `otp_rate:${phoneNumber}:hour`;
const cooldownKey = `otp_rate:${phoneNumber}:cooldown`;
// Check 60-second cooldown
if (await redis.exists(cooldownKey)) {
const ttl = await redis.ttl(cooldownKey);
throw new Error(`Please wait ${ttl} seconds before requesting a new code.`);
}
// Check hourly limit
const hourCount = parseInt(await redis.get(hourKey) || '0');
if (hourCount >= 5) {
throw new Error('Maximum OTP requests reached. Try again in one hour.');
}
// Set cooldown and increment counter
await redis.setex(cooldownKey, 60, '1');
await redis.incr(hourKey);
if (hourCount === 0) {
await redis.expire(hourKey, 3600);
}
}Best Practices
Code Generation
- Use 6-digit numeric codes. They balance security with usability. Four digits are too easy to guess; eight digits frustrate users.
- Always use a cryptographically secure random number generator (
crypto.randomIntin Node.js,secretsin Python). - Never reuse a code. Each OTP should be single-use and deleted immediately after successful verification.
Expiry & Storage
- Set a 5-minute expiry. This is the industry standard, balancing security with the time users need to switch between apps.
- Store codes in Redis or Memcached with automatic TTL expiry rather than your primary database.
- Never log OTP codes in plaintext. If you must log for debugging, hash them first.
User Experience
- Include the expiry duration in the SMS body: "expires in 5 minutes".
- Show a resend button in your UI with a 60-second countdown timer.
- Auto-focus the OTP input field and use
autocomplete="one-time-code"for automatic SMS autofill on mobile browsers. - Display the last 4 digits of the phone number so users know where to look: "Code sent to ***5678".
Security
- Use timing-safe comparison (
crypto.timingSafeEqualorhmac.compare_digest) to prevent timing attacks. - Limit verification attempts to 5 per code. After 5 failures, invalidate the code and require a new one.
- Log all OTP requests and verification attempts for fraud detection.
- Monitor for OTP pumping (automated requests to premium-rate numbers). Set up alerts for unusual spikes in OTP volume.
FAQ
What if the SMS is not delivered?
Listen for the message.status_update webhook. If the status is failed or you do not receive a delivered status within 30 seconds, offer the user an alternative: resend the SMS, try WhatsApp, or use a voice call OTP.
Can I use the same number for OTP and marketing SMS?
Technically yes, but we recommend using separate numbers for OTP and marketing. Carrier spam filters are more aggressive with numbers that send mixed-content traffic, which can delay time-sensitive OTP deliveries.
How do I handle international numbers?
Always use E.164 format (e.g., +4915112345678) when sending to international numbers. DIDfarm automatically routes through the best carrier path for each destination country. For best delivery rates, use a sender number local to the recipient's country.
Is there a test/sandbox mode?
Yes. Use the X-DIDfarm-Test: true header in your API requests. Test messages are not delivered to handsets but return realistic API responses and webhook events, letting you validate your integration without incurring costs.
Ready to integrate OTP?
See the full API reference with all parameters, error codes, and webhooks.