SMS OTP Verification: How to Build It in Node.js
By SendAPI Engineering · Last updated March 12, 2026 · 6 min read
Step-by-step guide to implementing SMS OTP verification in a Node.js app using SendAPI. Covers sending codes, verifying input, expiry, and rate limiting.
What is OTP Verification?
OTP (One-Time Password) verification is a form of two-factor authentication (2FA) where a short-lived numeric code is sent to a user's phone via SMS. The user enters the code to prove they control the phone number.
It's used for:
- Phone number verification at signup
- Login 2FA for security-sensitive apps
- Transaction confirmation in fintech and e-commerce
- Password reset as an alternative to email links
Why OTP Verification Matters: The Data
Two-factor authentication dramatically reduces account compromise. According to Google's Security Blog, enabling 2FA blocks 99.9% of automated bot attacks and 99% of bulk phishing attacks. A 2023 Microsoft report found that accounts with MFA enabled are 99.22% less likely to be compromised than accounts relying on passwords alone.
SMS OTP remains the most widely adopted 2FA method for consumer apps: it requires no app install, works on any phone with a SIM card, and achieves activation rates up to 3x higher than TOTP authenticator apps in consumer-facing products. The global OTP market is projected to reach $4.6 billion by 2030 (Grand View Research, 2024), driven by fintech, e-commerce, and healthcare compliance requirements.
Why SMS OTP vs Authenticator Apps?
SMS OTP works for any user with a phone number — no app install required. For consumer products, the activation rate is significantly higher than TOTP authenticator apps (Google Authenticator, Authy).
The tradeoff: SMS OTP is vulnerable to SIM-swap attacks in high-risk scenarios. For those cases, consider upgrading to TOTP or hardware keys.
Setting Up SendAPI
Install the SendAPI Node.js SDK:
npm install @sendapi/node
Initialize the client:
import SendAPI from '@sendapi/node'
const client = new SendAPI({ apiKey: process.env.SENDAPI_KEY })
Step 1: Send the OTP
Call the /verify/send endpoint with the user's phone number:
// POST /auth/send-otp
app.post('/auth/send-otp', async (req, res) => {
const { phoneNumber } = req.body
try {
const response = await client.verify.send({
to: phoneNumber,
channel: 'sms',
codeLength: 6,
expiresIn: 300, // 5 minutes in seconds
})
// Store the session token (maps to the OTP internally)
// Never store the code itself server-side
res.json({ sessionToken: response.sessionToken })
} catch (err) {
res.status(400).json({ error: err.message })
}
})
The user receives an SMS like:
> "Your SendAPI verification code is 847291. Expires in 5 minutes."
Step 2: Verify the Code
When the user submits the code from their phone:
// POST /auth/verify-otp
app.post('/auth/verify-otp', async (req, res) => {
const { sessionToken, code } = req.body
try {
const result = await client.verify.check({
sessionToken,
code,
})
if (result.valid) {
// OTP is correct — issue your auth token / session
const authToken = await createSession(result.phoneNumber)
res.json({ success: true, token: authToken })
} else {
res.status(401).json({ error: 'Invalid or expired code' })
}
} catch (err) {
res.status(400).json({ error: err.message })
}
})
Rate Limiting & Fraud Prevention
OTP endpoints are a common abuse target. SendAPI handles several protections automatically:
- Code expiry — configurable, default 10 minutes
- Attempt limiting — max 3 wrong guesses before the session is invalidated
- Send rate limiting — per phone number (prevents OTP flooding attacks)
- Delivery receipts — webhook events for delivered, failed, undelivered
You should also add your own application-level rate limiting:
import rateLimit from 'express-rate-limit'
const otpLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // max 5 OTP requests per IP per window
message: 'Too many OTP requests. Please try again later.',
})
app.post('/auth/send-otp', otpLimiter, async (req, res) => { ... })
Handling Edge Cases
Phone number already verified
Check your database before sending an OTP to avoid re-verifying already-verified users:
const user = await db.users.findOne({ phone: phoneNumber })
if (user?.phoneVerified) {
return res.status(400).json({ error: 'Phone already verified' })
}
International numbers
Always accept E.164 format (+14155552671). Validate on the client before sending:
import { parsePhoneNumber, isValidPhoneNumber } from 'libphonenumber-js'
if (!isValidPhoneNumber(phoneNumber)) {
return res.status(400).json({ error: 'Invalid phone number' })
}
const e164 = parsePhoneNumber(phoneNumber).format('E.164')
Resend flow
Allow resending after a cooldown period, not immediately:
// Check last send timestamp before allowing resend
const lastSent = cache.get(`otp_sent:${phoneNumber}`)
if (lastSent && Date.now() - lastSent < 60000) {
return res.status(429).json({ error: 'Please wait 60 seconds before resending' })
}
Full Flow Diagram
User enters phone → POST /auth/send-otp
↓
SendAPI sends SMS
↓
User enters 6-digit code → POST /auth/verify-otp
↓
SendAPI validates code
↓
Issue session / JWT