Two-factor authentication is table stakes now, and SMS is still the channel with the widest reach — no app install, no hardware key, works on every phone ever made. But "send a code to a phone" hides a surprising amount of production complexity: expiry windows, attempt limits, rate limiting, carrier filtering, and compliance rules that vary by country.
This is a practical walkthrough of the full flow in Node.js, from generating the code to the security details that separate a demo from something you can ship.
The flow at a glance
1. User enters phone number
2. Server generates a 6-digit OTP, stores a HASH of it with an expiry timestamp
3. Server sends the OTP via an SMS API
4. User enters the code
5. Server compares hashes, enforces expiry + attempt limits
6. On success: mark phone as verified, invalidate the code
Two design decisions matter more than everything else: never store the OTP in plain text, and never tell the user whether the phone number or the code was wrong.
Step 1 — Generate and store the OTP
const crypto = require('crypto');
function createOtpRecord(phone, ttlMs = 5 * 60 * 1000) {
const code = crypto.randomInt(100000, 999999).toString();
return {
phone,
codeHash: crypto.createHash('sha256').update(code).digest('hex'),
expiresAt: Date.now() + ttlMs,
attempts: 0,
maxAttempts: 5
};
}
Notes:
- Use
crypto.randomInt, notMath.random(). Predictable OTPs are a real attack vector. - 6 digits with a 5-minute window is the industry default — long enough for slow SMS delivery, short enough to limit exposure.
- Store the hash. If your database leaks, the codes are useless.
Step 2 — Send it via an SMS API
Here's a generic send using a CPaaS SDK — this example uses Tekhook, but the pattern is identical across providers:
const Tekhook = require('tekhook-sdk');
const client = new Tekhook(process.env.TEKHOOK_API_KEY, process.env.TEKHOOK_API_SECRET);
async function sendOtp(phone, code) {
await client.messages.create({
to: phone,
from: 'YOURSENDER', // registered sender ID
text: `Your verification code is ${code}. Valid for 5 minutes. Do not share it with anyone.`
});
}
A few production details people miss:
- Sender ID rules differ by country. Some countries (India, UAE, Saudi Arabia) require pre-registered sender IDs or templates. If you send globally, route through a provider that handles this per-country, or you'll silently fail delivery.
- Message content matters. Some carriers filter messages that look like marketing. Keep OTP texts plain and transactional.
- Alphanumeric senders don't work in the US. US traffic requires a registered 10DLC brand/campaign or a toll-free number. If you skip this, carriers will block or heavily filter your traffic.
If you don't have an SDK handy, the same call over REST looks like:
await fetch('https://api.tekhook.co/v1/messages', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.TEKHOOK_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
to: phone,
from: 'YOURSENDER',
text: `Your verification code is ${code}. Valid for 5 minutes.`
})
});
Step 3 — Verify with expiry and attempt limits
async function verifyOtp(store, phone, submittedCode) {
const record = await store.get(`otp:${phone}`);
if (!record) return { ok: false, reason: 'expired' };
if (Date.now() > record.expiresAt) {
await store.del(`otp:${phone}`);
return { ok: false, reason: 'expired' };
}
if (record.attempts >= record.maxAttempts) {
await store.del(`otp:${phone}`);
return { ok: false, reason: 'locked' };
}
const submittedHash = crypto.createHash('sha256').update(submittedCode).digest('hex');
if (submittedHash !== record.codeHash) {
record.attempts += 1;
await store.set(`otp:${phone}`, record);
return { ok: false, reason: 'invalid' };
}
await store.del(`otp:${phone}`); // one-time use — always invalidate
return { ok: true };
}
The critical rules:
- One-time use. A verified code must be deleted immediately. Replays are how accounts get taken over.
- Cap attempts at 3–5. Six digits is only 1,000,000 possibilities; unlimited attempts makes brute force trivial.
- Generic error responses. "Invalid code or expired" — never reveal which one. An attacker learning "the code is right but expired" gets information for free.
Step 4 — Rate limit before the SMS API
SMS pumping fraud is real: attackers request thousands of OTPs to premium-rate or controlled numbers and stick you with the bill. Protect the request endpoint itself:
// conceptual — use your framework's limiter or a sliding window in Redis
app.post('/otp/request', rateLimit({
windowMs: 60 * 60 * 1000,
max: 3, // max 3 OTP requests per hour per IP+number
keyGenerator: (req) => `${req.ip}:${req.body.phone}`
}), requestOtpHandler);
Also consider:
- Number validation (E.164 format) before sending — burning an SMS on a malformed number is pure waste.
- A CAPTCHA or similar after the second request from the same number.
- Fraud detection — many CPaaS providers offer number-risk scoring; use it if you see abuse.
Compliance traps worth knowing
- US: A2P 10DLC registration is mandatory for application-to-person SMS. Unregistered traffic gets filtered.
- India: DLT registration for sender IDs and templates is required.
- EU/UK: GDPR applies to the phone numbers you're processing — make sure your retention policy deletes OTP records promptly (they should live minutes, not months).
- Opt-out language: even for OTPs, carriers in some markets expect clear sender identification.
Testing without burning credits
Use your provider's sandbox/test credentials, and mock the SMS sender in tests:
// test double
const fakeSms = { sent: [] };
async function sendOtp(phone, code, sender = realSender) {
await sender.send(phone, `Your verification code is ${code}...`);
}
Verify the full lifecycle: request → receive (mocked) → wrong code → right code → replay attempt → expiry.
Wrapping up
The OTP flow itself is simple — the engineering is in the edges: hashing, expiry, attempt limits, rate limiting, and per-country delivery rules. Get those right and SMS verification is one of the most reliable ways to confirm a user controls a phone number, anywhere in the world.
If you're evaluating SMS APIs, I put together a checklist of what actually matters at scale — delivery rates by route, sender ID support per country, and latency under load — in the Tekhook developer docs.
What's your OTP war story? Dropped messages in a specific country, carrier filtering surprises, SIM-swap attacks — the comments are the best part of these posts.
Top comments (0)