Zurück zum Blog
Custom Software

SMS OTP Verification: How to Integrate It Safely

How SMS OTP verification is actually integrated: server-side code generation, hashed storage, lifetime and attempt limits, delivery, cost and fallback channels.

EntegrasyonGüvenlikÖzel YazılımMobil Uygulama

Short answer: an SMS OTP integration is not "plugging in a service that sends text messages". The SMS part is the easiest and the last part; the real work is three server-side rules: the server generates the code and never returns it to the client, the code has a short life, and both attempts and sends are rate limited. Without those three, the integration looks like it works but provides no security.

Where SMS OTP belongs — and where it does not

A one-time passcode proves that the user can actually receive messages on that number. That makes it useful in three places: verifying a phone number, passwordless sign-in, and confirming a risky action (a transfer, a change of bank details, granting admin rights). In all three the question is the same: is the owner of this number here right now?

Where it does not belong is in the middle of every step of a sign-up flow. Asking for a code at each step kills conversion; ask only at the moment the number genuinely matters. Once a number is verified, asking again adds no security.

The code is not generated on the client and never sent back to it

The most serious mistake we see in existing integrations is this: the code is generated in the mobile app or browser and then handed to the SMS provider, or the server generates it but returns it in the API response "for debugging". Either one makes verification meaningless, because the attacker never needs to receive the message.

The correct flow is one-way: the server generates the code, stores a hash of it rather than the code itself, sends it to the SMS provider, and returns nothing to the client but "a code has been sent". When the verification request arrives, the hash of the submitted code is compared with the stored record.

// The code is generated on the server, stored on the server, and NEVER returned
// to the client.
import { createHash, randomInt } from 'node:crypto';

const CODE_TTL_MS = 3 * 60 * 1000; // 3 minutes
const MAX_ATTEMPTS = 5;

type OtpRecord = {
  phone: string;
  codeHash: string;
  expiresAt: number;
  attempts: number;
  used: boolean;
};

function generateCode(): string {
  // Math.random() is not cryptographic; it produces guessable codes.
  return String(randomInt(0, 1_000_000)).padStart(6, '0');
}

function hash(code: string, phone: string): string {
  // Mix the phone number in: the same code cannot be replayed on another number.
  return createHash('sha256').update(code + '|' + phone).digest('hex');
}

function verify(record: OtpRecord, submitted: string, now: number) {
  if (record.used) return { ok: false, reason: 'code_already_used' };
  if (now > record.expiresAt) return { ok: false, reason: 'expired' };
  if (record.attempts >= MAX_ATTEMPTS) return { ok: false, reason: 'too_many_attempts' };

  record.attempts += 1; // Incremented on failures too — that is the real defence.

  if (hash(submitted, record.phone) !== record.codeHash) {
    return { ok: false, reason: 'wrong_code' };
  }

  record.used = true; // Single use: a correct code does not work twice.
  return { ok: true };
}

Four details in that function matter: the code comes from a cryptographic random source, the phone number is mixed into the hash, the counter increments on failed attempts too, and a correct code is single use. The fourth one is missing from most projects, and a code that is not single use is an open door for anyone who has seen the message.

Three counters: lifetime, attempts and sends

OTP security does not come from one mechanism but from three separate counters. All three live on the server; nothing coming from the client is trusted.

  • Code lifetime: somewhere between two and five minutes covers most scenarios. A long life widens the window in which an intercepted message is still useful.
  • Attempt count: after four to six wrong tries the record is closed and a new code must be requested. The counter increments on the verification request, whatever its result.
  • Send count: cap how many codes one number and one IP can trigger per minute and per hour. This limit is about the invoice as much as security — an unlimited "resend" button burns both your budget and your sender reputation.
  • Cooldown: the resend button stays locked for at least 30 to 60 seconds, and that lock is enforced on the server; a countdown in the interface is not protection.
What breaks an OTP is not a guessed code but unlimited guessing. A six-digit code is a one-in-a-million shot; without a five-attempt limit the same code can be tried thousands of times, and the maths turns against you.

An SMS travels over a network: delivery and cost

The second reality of SMS integration is the moment the message leaves your control. Carrier queues, ported numbers, international destinations and the sender ID all affect delivery. Before comparing price lists, ask any provider three things: can delivery reports be pulled from the API, does OTP traffic run in a queue separate from marketing traffic, and are international destinations supported.

Sender ID registration and commercial-messaging rules vary by country and change over time, and the obligations that apply to verification messages are not the same as those for marketing messages. Confirm registration and consent requirements in writing with your provider and the relevant authority before development starts — this is not legal advice but a line that belongs in the project plan. The same logic applies to the retention decisions in our GDPR-compliant website article: a verified phone number is personal data and its retention period is decided up front.

On cost, remember that messages are billed per unit and failed attempts are billed too. In an application that does not limit the resend flow, half the monthly invoice is codes nobody ever used.

Fallback channels: voice call and app-based codes

SMS does not always arrive. Coverage gaps, roaming and carrier filters lock out real users. A serious integration therefore defines at least one fallback: reading the code out over a voice call, or delivering it through the WhatsApp Business API. The fallback should trigger on the second resend request, not the first.

For high-risk actions, SMS itself is the weakest link: in a SIM-swap attack the code lands on the attacker's phone. For banking-grade operations an app-based code (TOTP) or a device approval is stronger, and SMS stays where it belongs — as number verification, not as the only security layer. For the wider attack surface, see our article on website security.

The five most common integration mistakes

  • Returning the code in the API response — usually a leftover line from the test environment that reaches production.
  • Storing the code in plain text; one database leak exposes every active code.
  • Keeping the attempt counter on the client — a counter held in the mobile app is bypassed by calling the API directly.
  • Not making a correct code single use, so the same code keeps working until it expires.
  • Saying "this number is not registered" in the error message, which lets an attacker enumerate your users. Verification should return the same generic message in every case.

Build it yourself or buy an authentication provider?

There are two routes. A hosted authentication provider (user management and OTP together) starts fast and manages most of those counters for you, at the price of per-user fees and a dependency. Building OTP inside your own application means writing the three counters and the provider integration yourself — a few days of work, with a cost that stays flat as the user base grows.

The decision rule is simple: if you already write your own user management (sign-up, roles, sessions), write the OTP too; if you outsource authentication entirely, do not split OTP out of it. Splitting the two means the same user is stored in two places, which turns into constant synchronisation work on the API integration side.

Conclusion

An SMS OTP integration starts with three decisions, not with a provider: where the code is generated, how long it lives, and how many times it can be tried. Get those right and switching provider is a day of work; get them wrong and the most expensive message bundle buys you nothing. If you want verification added to an existing product or an authentication flow designed from scratch, our custom software team can design it with you — start by requesting a quote.

Let's Build Your Project

Get a free consultation for your website, mobile app, or corporate software project.

Get a Free QuoteExplore our Custom Software service