DEV Community

minba adni
minba adni

Posted on

Build a Production-Ready OTP Verification Flow with a Global SMS API

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
Enter fullscreen mode Exit fullscreen mode

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
  };
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  • Use crypto.randomInt, not Math.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.`
  });
}
Enter fullscreen mode Exit fullscreen mode

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.`
  })
});
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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}...`);
}
Enter fullscreen mode Exit fullscreen mode

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)