← All articles

OTP via SMS: a secure implementation, and when to choose TOTP or a passkey instead

22 min readDeutsch

Implementing one-time codes via SMS: generation, hashing, rate limiting, TTL, code in PHP, Node.js and Python. Plus SMS vs TOTP vs passkey, SIM swap and NIST.

Most online shops, SaaS applications and banking systems use OTP via SMS as the basic mechanism for two-factor authentication (2FA), confirming a password change or approving a payment. The implementation looks simple — generate six digits, send an SMS, check them on the way back — but a production implementation has many details that decide whether your system withstands a brute-force attack, a SIM-swap incident or a spam wave through your SMS gateway.

This article is a practical guide: not a marketing overview of “what is OTP”, but a concrete implementation with working code examples in PHP, Node.js and Python, covering all the security best practices you should follow in production.

Don't want to build it yourself? Use the Verify API. TopSMS has OTP ready-made: POST /api/verify/send generates and sends the code, POST /api/verify/check verifies it. You neither store nor generate the code (the gateway keeps only an HMAC fingerprint of it) and the gateway enforces the limits: 5 attempts per code, at most 5 codes per hour to one number and 30 s between two codes. With a test key nothing is sent or charged and the code is always 123456. The rest of this article is for the case where you want OTP fully under your control in your own code.

Why SMS OTP still makes sense in 2026

SMS OTP has strong competition today:

  • TOTP apps (Google Authenticator, Authy, 1Password): offline, immune to SIM swap, but they require the user to install and set up an app — onboarding friction
  • Push notifications (banks, your own mobile app): the best UX, but only for users who have your mobile app
  • WebAuthn / FIDO2 passkeys: the strongest security, but adoption among ordinary users is still limited
  • E-mail OTP: easy, but mailboxes get compromised more often than phones

SMS OTP remains the default choice for:

  1. Onboarding new users (registration, phone verification) — needs no app, works universally
  2. Account recovery — a fallback for when the user loses access to TOTP
  3. Transaction confirmation — payments, transfers, high-value changes
  4. Web applications without a mobile client — internal company systems, B2B portals
  5. Mass-market consumer apps — where you cannot assume technical skill

Most banks and fintech apps in the EU still use SMS OTP, at least as a secondary factor. For most B2C applications SMS is not in retreat — on the contrary, “low-friction” 2FA is growing, where SMS is the only factor for everyday actions and TOTP/passkeys are reserved for sensitive ones.

SMS vs TOTP vs passkey: when each method is enough

The second factor is always “something you have” (a phone, an app, a hardware key). The methods differ in how they prove you actually hold that device:

MethodHow it worksMain weaknessMain advantage
SMS OTPthe server texts a six-digit codeSIM swap, real-time phishing, malware reading SMSworks on every phone without installing anything
TOTPan app (Google Authenticator, Authy, 1Password) generates a code every 30 sreal-time phishing, loss of the phone without backup codesthe code never travels over the network
Pushyour mobile app shows “Approve”only for users with your app, “approval fatigue”the best UX
Passkey / FIDO2a cryptographic signature bound to the domain (phone, YubiKey)lower adoption, more complex account recoveryphishing-resistant

What makes SMS vulnerable

  • SIM swap. The attacker persuades the operator (or an employee) to move the victim's number to a new SIM and receives their SMS. It is rare but not impossible; operators have tightened the process, yet for high-value accounts you should plan for it.
  • Real-time phishing. A fake login page asks for the password and the SMS code, and the attacker uses it within seconds. This is the most common attack today and works just as well against TOTP. Only a passkey resists it, because the signature is bound to the domain.
  • Malware on the phone. Banking trojans on Android read incoming SMS. iOS is more closed in this respect.
  • SS7. The operators' signalling protocol has known weaknesses and redirecting SMS is possible for an attacker with network access. In practice these are attacks on highly exposed individuals, not on ordinary shop customers.

TOTP sidesteps these network attacks because the code is created locally on the phone. The price is onboarding: the user has to install an app and scan a QR code, and if they lose the phone without backup codes, they lose access.

What NIST says

NIST SP 800-63B classifies verification over the public telephone network (SMS and voice call) as a “restricted” authenticator. It does not forbid them, but it wants the provider to assess the risk, inform users about it and offer another method too. For level AAL2 SMS is acceptable, for AAL3 (health records, government) it is not. More in the Compliance chapter below.

Practical recommendation

SMS OTP is enough for an ordinary SaaS application, an online shop, a customer portal or registration, where users are technically diverse and losing the account does not mean direct financial damage. Examples: verifying the phone at registration, confirming an order, logging in to a shop.

Add TOTP or a passkey when the account has elevated privileges (administration, server access), you handle sensitive data (health, financial, legal) or regulation requires it. The cleanest model is step-up: SMS for everyday actions, TOTP or a passkey for a password change, a payment or a data export (see the Advanced techniques chapter).

Require a hardware key (FIDO2) for administrators, finance managers and wherever compliance (PCI DSS, banking regulation) prescribes a stronger factor.

Whatever you choose, offer a fallback path: backup codes, a second method or a verified support procedure. A user without access to their phone is otherwise lost.

One more practical note on delivery: TopSMS refunds credit only for an SMS that the gateway never sent at all (an error on its side). An SMS that was sent but not delivered by the operator (phone switched off, invalid number) is not refunded, and an OTP should never be retried blindly anyway — a new attempt means a new code. Let your application handle “Send again”, and read the delivery status from the webhook or by querying the message status.

Security principles — what you must guarantee

Before you write a single line of code, you need clear answers to these seven questions:

1. Code length and format

LengthCombinationsBrute force at 1 attempt/sSuitable for
4 digits10,000~1.5 hoursNot recommended
5 digits100,000~14 hoursLow-risk consumer
6 digits1,000,000~6 daysStandard for consumer + banking
8 digits100 million~3 yearsHigh security (TOTP grade)

A six-digit numeric code is today's standard. Do not use alphanumeric codes — they hurt UX (the user has to switch keyboards on a phone) and the security gain is marginal, because rate limiting does the real work.

2. TTL (time to live) — how long the code is valid

Myth: “the shorter, the better.” Truth: you have to balance security and UX.

  • 30 seconds: the user cannot read the SMS and type it in time
  • 1 minute: for highly sensitive operations (banking payments)
  • 2–5 minutes: the sweet spot for most use cases
  • 10 minutes: for account recovery, when the user may have degraded access to the phone
  • More than 10 minutes: a needlessly large attack window

Recommendation by purpose:

'login'    → 2 minutes
'register' → 5 minutes (the user fills in a form for the first time, long input)
'payment'  → 1 minute (banking, no exceptions)
'recovery' → 10 minutes
'2fa'      → 2 minutes

3. Rate limiting on 3 levels

Without a rate limit an attacker (or a bot) can run up your SMS bill through your gateway, exhaust your daily limit and cause real damage. Implement all three layers:

Per phone number (against spam):

  • Max 3 OTP requests per hour to the same number
  • Max 5 OTP requests per 24 hours to the same number

Per IP address (against enumeration attacks):

  • Max 10 OTP requests per minute from one IP
  • Max 50 per hour

Per failed verification (against brute force):

  • Max 5 wrong attempts per issued code
  • After 5 failures → invalidate the code, the user must request a new one
  • After 10 wrong attempts per hour on a number → temporary block for 30 minutes

4. Hashing — do not store codes in plaintext

If your database leaks, plaintext OTP records can be abused in the short window before they expire. Hash them exactly like passwords:

  • bcrypt (recommended): a deliberately slow hash slows down brute force
  • argon2id (the more modern alternative): preferred where available
  • SHA-256 + salt: the minimum; fast, but that speed benefits the attacker

OTP codes are so short that a fast hash (SHA) would allow brute-forcing the whole keyspace in seconds. Use bcrypt with a cost of at least 10 — verification then takes ~100 ms, which is acceptable from a UX perspective but slows the attacker considerably.

5. Cryptographic randomness — not Math.random()

Most developers know Math.random() in JavaScript or rand() in PHP. Do not use them for security purposes. These generators are predictable, and an attacker who captures a few generated codes can predict the next ones.

Always use a cryptographic random source:

  • JavaScript / Node.js: crypto.randomInt() or crypto.randomBytes()
  • PHP: random_int() or random_bytes()
  • Python: secrets.randbelow() or secrets.token_hex()

Never:

  • Math.random() in JS
  • rand() / mt_rand() in PHP
  • random.randint() in Python (it uses the random module, not secrets)

6. Single-use enforcement

After a successful verification the code must be invalidated immediately (deleted or marked as consumed). An attacker who obtained the code from the user's memory or by intercepting the SMS (less common but possible) could otherwise use it more than once.

Likewise invalidate the code once the maximum number of failed attempts is reached; this puts a hard ceiling on guessing.

7. Constant-time comparison

When verifying a hash, never use a plain == on strings. Most libraries (bcrypt.compare, password_verify) have constant-time comparison built in. Manually you would use crypto.timingSafeEqual() in Node.js, hash_equals() in PHP or hmac.compare_digest() in Python.

Database schema

A universal schema that covers all use cases:

CREATE TABLE otp_codes (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  phone VARCHAR(20) NOT NULL,
  code_hash VARCHAR(255) NOT NULL,
  purpose VARCHAR(50) NOT NULL,
  expires_at DATETIME NOT NULL,
  consumed_at DATETIME NULL,
  attempts INT DEFAULT 0,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  ip VARCHAR(45),
  user_agent_hash VARCHAR(64),
  INDEX idx_phone_purpose (phone, purpose, consumed_at),
  INDEX idx_expires (expires_at)
);

The purpose field allows different TTL and rate-limit policies for different actions. The ip and user_agent_hash fields are the basis for fraud detection — if the request came from browser A and the verification from browser B, that is suspicious.

Regular cleanup: run a cron job every hour that deletes expired records older than 24 hours. Keep consumed records for another 30 days for forensic traces.

DELETE FROM otp_codes
WHERE (expires_at < DATE_SUB(NOW(), INTERVAL 24 HOUR) AND consumed_at IS NULL)
   OR (consumed_at < DATE_SUB(NOW(), INTERVAL 30 DAY));

Implementation in Node.js (with Prisma)

Assume you use the TopSMS REST API to deliver the SMS. The message texts, the sender name MojeFirma and the error messages in the samples are placeholders — replace them with your own wording and your approved sender ID. First, generating and sending:

import crypto from 'crypto'
import bcrypt from 'bcryptjs'
import { prisma } from '@/lib/prisma'

const TTL_BY_PURPOSE: Record<string, number> = {
  login: 2,
  register: 5,
  payment: 1,
  recovery: 10,
  '2fa': 2,
}

export async function generateAndSendOtp(
  phone: string,
  purpose: string,
  ip: string,
) {
  // 1) Rate limit per phone (max 3/hour)
  const hourAgo = new Date(Date.now() - 60 * 60 * 1000)
  const recentCount = await prisma.otpCode.count({
    where: { phone, createdAt: { gte: hourAgo } },
  })
  if (recentCount >= 3) {
    throw new Error('Příliš mnoho OTP požadavků. Zkuste to za hodinu.')
  }

  // 2) Rate limit per IP (max 10/minute)
  const minuteAgo = new Date(Date.now() - 60 * 1000)
  const ipCount = await prisma.otpCode.count({
    where: { ip, createdAt: { gte: minuteAgo } },
  })
  if (ipCount >= 10) {
    throw new Error('Příliš mnoho požadavků z této IP.')
  }

  // 3) Generate cryptographic random 6-digit code
  const code = crypto.randomInt(100000, 1000000).toString()
  const codeHash = await bcrypt.hash(code, 10)

  // 4) TTL based on purpose
  const ttlMin = TTL_BY_PURPOSE[purpose] ?? 5
  const expiresAt = new Date(Date.now() + ttlMin * 60 * 1000)

  // 5) Persist
  await prisma.otpCode.create({
    data: { phone, codeHash, purpose, expiresAt, ip },
  })

  // 6) Send via TopSMS REST API
  const smsText = `Váš ověřovací kód: ${code}. Platí ${ttlMin} minut. Nezveřejňujte nikomu.`

  const resp = await fetch('https://www.topsms.cz/api/sms/send', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${process.env.TOPSMS_CLIENT_ID}:${process.env.TOPSMS_SECRET}`,
    },
    body: JSON.stringify({
      to: phone,
      from: 'MojeFirma', // your approved sender ID
      text: smsText,
    }),
  })

  if (!resp.ok) {
    // SMS delivery failed - record stays in DB but won't be used; cleanup will remove it
    throw new Error('Odeslání SMS selhalo')
  }

  return { ok: true, expiresAt }
}

And the verification:

export async function verifyOtp(
  phone: string,
  purpose: string,
  code: string,
) {
  // 1) Najdi nejnovější platný kód pro phone+purpose
  const record = await prisma.otpCode.findFirst({
    where: {
      phone,
      purpose,
      consumedAt: null,
      expiresAt: { gt: new Date() },
    },
    orderBy: { createdAt: 'desc' },
  })

  if (!record) {
    return { ok: false, reason: 'no-pending-code' }
  }

  // 2) Check max attempts
  if (record.attempts >= 5) {
    return { ok: false, reason: 'too-many-attempts' }
  }

  // 3) Bcrypt verify (constant-time)
  const valid = await bcrypt.compare(code, record.codeHash)

  if (!valid) {
    await prisma.otpCode.update({
      where: { id: record.id },
      data: { attempts: { increment: 1 } },
    })
    return { ok: false, reason: 'invalid-code' }
  }

  // 4) Mark as consumed (single-use enforcement)
  await prisma.otpCode.update({
    where: { id: record.id },
    data: { consumedAt: new Date() },
  })

  return { ok: true }
}

Implementation in PHP

The same logic in PHP 8+ with PDO:

<?php
function generateAndSendOtp(string $phone, string $purpose, string $ip): array {
    $pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'));
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

    // Rate limit per phone
    $stmt = $pdo->prepare(
        "SELECT COUNT(*) FROM otp_codes
         WHERE phone = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)"
    );
    $stmt->execute([$phone]);
    if ((int)$stmt->fetchColumn() >= 3) {
        throw new Exception('Příliš mnoho OTP požadavků');
    }

    // Rate limit per IP
    $stmt = $pdo->prepare(
        "SELECT COUNT(*) FROM otp_codes
         WHERE ip = ? AND created_at > DATE_SUB(NOW(), INTERVAL 1 MINUTE)"
    );
    $stmt->execute([$ip]);
    if ((int)$stmt->fetchColumn() >= 10) {
        throw new Exception('Příliš mnoho požadavků z této IP');
    }

    // Cryptographic random 6-digit code
    $code = sprintf('%06d', random_int(0, 999999));
    $codeHash = password_hash($code, PASSWORD_BCRYPT);

    $ttlMap = ['login' => 2, 'register' => 5, 'payment' => 1, 'recovery' => 10, '2fa' => 2];
    $ttl = $ttlMap[$purpose] ?? 5;

    $stmt = $pdo->prepare(
        "INSERT INTO otp_codes (phone, code_hash, purpose, expires_at, ip)
         VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL ? MINUTE), ?)"
    );
    $stmt->execute([$phone, $codeHash, $purpose, $ttl, $ip]);

    // Send via TopSMS
    $text = "Váš ověřovací kód: $code. Platí $ttl minut. Nezveřejňujte nikomu.";
    $ch = curl_init('https://www.topsms.cz/api/sms/send');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/json',
            'Authorization: Bearer ' . getenv('TOPSMS_CLIENT_ID') . ':' . getenv('TOPSMS_SECRET'),
        ],
        CURLOPT_POSTFIELDS => json_encode([
            'to' => $phone,
            'from' => 'MojeFirma',
            'text' => $text,
        ]),
        CURLOPT_TIMEOUT => 10,
    ]);
    curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($httpCode !== 200) {
        throw new Exception('Odeslání SMS selhalo');
    }

    return ['ok' => true, 'expires_in_seconds' => $ttl * 60];
}

function verifyOtp(string $phone, string $purpose, string $code): array {
    $pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'));
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

    $stmt = $pdo->prepare(
        "SELECT id, code_hash, attempts FROM otp_codes
         WHERE phone = ? AND purpose = ? AND consumed_at IS NULL AND expires_at > NOW()
         ORDER BY created_at DESC LIMIT 1"
    );
    $stmt->execute([$phone, $purpose]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!$row) {
        return ['ok' => false, 'reason' => 'no-pending-code'];
    }
    if ((int)$row['attempts'] >= 5) {
        return ['ok' => false, 'reason' => 'too-many-attempts'];
    }

    if (!password_verify($code, $row['code_hash'])) {
        $pdo->prepare("UPDATE otp_codes SET attempts = attempts + 1 WHERE id = ?")
            ->execute([$row['id']]);
        return ['ok' => false, 'reason' => 'invalid-code'];
    }

    $pdo->prepare("UPDATE otp_codes SET consumed_at = NOW() WHERE id = ?")
        ->execute([$row['id']]);

    return ['ok' => true];
}

Implementation in Python (FastAPI + SQLAlchemy)

import os
import secrets
import bcrypt
import requests
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from .models import OtpCode

TOPSMS_CLIENT_ID = os.environ['TOPSMS_CLIENT_ID']
TOPSMS_SECRET = os.environ['TOPSMS_SECRET']
TTL_BY_PURPOSE = {
    'login': 2,
    'register': 5,
    'payment': 1,
    'recovery': 10,
    '2fa': 2,
}


def generate_and_send_otp(db: Session, phone: str, purpose: str, ip: str) -> dict:
    now = datetime.utcnow()

    # Rate limit per phone
    recent = (
        db.query(OtpCode)
        .filter(OtpCode.phone == phone, OtpCode.created_at > now - timedelta(hours=1))
        .count()
    )
    if recent >= 3:
        raise ValueError('Příliš mnoho OTP požadavků')

    # Rate limit per IP
    ip_recent = (
        db.query(OtpCode)
        .filter(OtpCode.ip == ip, OtpCode.created_at > now - timedelta(minutes=1))
        .count()
    )
    if ip_recent >= 10:
        raise ValueError('Příliš mnoho požadavků z této IP')

    # Cryptographic random code
    code = f'{secrets.randbelow(1_000_000):06d}'
    code_hash = bcrypt.hashpw(code.encode(), bcrypt.gensalt())

    ttl_min = TTL_BY_PURPOSE.get(purpose, 5)
    record = OtpCode(
        phone=phone,
        code_hash=code_hash.decode(),
        purpose=purpose,
        expires_at=now + timedelta(minutes=ttl_min),
        ip=ip,
    )
    db.add(record)
    db.commit()

    # Send via TopSMS
    text = f'Váš ověřovací kód: {code}. Platí {ttl_min} minut. Nezveřejňujte nikomu.'
    resp = requests.post(
        'https://www.topsms.cz/api/sms/send',
        headers={
            'Content-Type': 'application/json',
            'Authorization': f'Bearer {TOPSMS_CLIENT_ID}:{TOPSMS_SECRET}',
        },
        json={'to': phone, 'from': 'MojeFirma', 'text': text},
        timeout=10,
    )
    resp.raise_for_status()

    return {'ok': True, 'expires_in_seconds': ttl_min * 60}


def verify_otp(db: Session, phone: str, purpose: str, code: str) -> dict:
    record = (
        db.query(OtpCode)
        .filter(
            OtpCode.phone == phone,
            OtpCode.purpose == purpose,
            OtpCode.consumed_at == None,
            OtpCode.expires_at > datetime.utcnow(),
        )
        .order_by(OtpCode.created_at.desc())
        .first()
    )

    if not record:
        return {'ok': False, 'reason': 'no-pending-code'}
    if record.attempts >= 5:
        return {'ok': False, 'reason': 'too-many-attempts'}

    if not bcrypt.checkpw(code.encode(), record.code_hash.encode()):
        record.attempts += 1
        db.commit()
        return {'ok': False, 'reason': 'invalid-code'}

    record.consumed_at = datetime.utcnow()
    db.commit()
    return {'ok': True}

Frontend UX — the details that decide

The backend is done. Now the frontend:

HTML input with autocomplete

<input
  type="text"
  inputmode="numeric"
  autocomplete="one-time-code"
  pattern="\d{6}"
  maxlength="6"
  placeholder="••••••"
  aria-label="Verification code"
/>

The attribute autocomplete="one-time-code" lets iOS and Android automatically offer the OTP read from the SMS notification — the user taps the suggestion and the code is filled in. A big improvement in conversion.

Countdown timer for expiry

Always show the user how much time is left. After expiry, disable the input and offer a “Send again” button with a 60-second cooldown, so the user does not spam the generator.

Masking the phone number

Show the user a masked phone number on the confirmation page: +49 ••• ••• 123 (only the last 3 digits). It helps the user check that the code is going to the right number, without showing the full number to someone looking over their shoulder.

Backup option always visible

Below the input offer “Didn't receive the SMS?” → an alternative path (e-mail OTP, support contact, TOTP backup). Some users have trouble receiving SMS (network outage, full inbox, wrong number) — without a backup option you lose them.

Common mistakes and how to avoid them

  1. Storing the code in plain text — if the DB leaks, the codes are immediately usable. Always hash.
  2. Math.random() in JS / rand() in PHP — predictable, breakable in seconds. Use cryptographic randomness.
  3. No rate limit — a bot exhausts your SMS quota and the bill goes through the roof. Always three layers (phone, IP, attempts).
  4. TTL longer than 10 minutes — the attack window grows needlessly. For everyday purposes 2–5 min, for recovery at most 10 min.
  5. Sharing the code across devices — an attacker intercepting the SMS can use the code on their own device. Session binding: store a hash of the session ID or the IP in the OTP record and verify that the verification came from the same session.
  6. Logging the plaintext code — even in error logs. Audit log output regularly. Log only the hash or the last 2 digits.
  7. No throttle per phone — an attacker with rotating IPs and the same target phone can cause a DoS via SMS. A strict per-phone limit (max 3/hour) solves it.
  8. Not tracking consumption — without consumed_at checks, the user (or an attacker) can use the same code more than once.
  9. Not stopping after max attempts — someone who guesses 100 times may eventually hit. Invalidate after 5 failures.
  10. No cleanup of expired records — the table grows forever and queries slow down. Cron every hour.

Advanced techniques for production

Risk-based authentication

Do not use OTP for every login. If the user logs in from a known device and IP (detectable via cookie + IP fingerprint), skip the OTP. Require it only for:

  • Login from a new geo-location
  • Login from a new device (no “remember me” cookie)
  • High-value actions (payment, account deletion, password change, 2FA change)

Higher conversion, lower SMS costs, better UX.

Step-up authentication

Instead of a binary “OTP or no OTP” model, implement several levels:

  • Level 0 — no OTP, password only. For low-value actions (viewing the account, small purchases).
  • Level 1 — SMS OTP. For medium-value actions (a basket worth more than, say, €200).
  • Level 2 — SMS OTP + TOTP, or a passkey. For high-value actions (payment, password change, data export).

Backup codes

Generate 10 single-use backup codes for the user, which they can print or store in a password manager. If they have no access to their phone (loss, theft, operator outage), they can log in with one of the backup codes. The implementation is similar to OTP, only the TTL is permanent (until consumed) and generation happens once.

SIM swap detection

A SIM swap attack = the attacker persuades the operator to move the phone number to a new SIM card. It can be detected through:

  • A change in behaviour pattern — the user suddenly logs in from a different geo-location and device and requests an OTP
  • A very short time between a password reset and the first login from a new device

If you detect a SIM swap risk, force the user to a higher verification level (TOTP, biometrics) or temporarily restrict high-value actions.

Compliance — GDPR, PSD2, NIST

GDPR

A phone number is personal data within the meaning of Art. 4 GDPR. For the processing:

  • Legal basis: Art. 6(1)(b) (performance of a contract) — because the OTP secures the account that is the subject of the contract with the user
  • Retention: delete consumed records after 30 days, unused ones after 24 hours
  • Information: state in the privacy policy that the phone number and the SMS content are processed

NIST 800-63B — Authenticator Assurance Levels

NIST (a US standard, but often referenced in the EU too) classifies SMS OTP as a “restricted authenticator” — less secure than TOTP or FIDO2 because of the risk of SIM swap and SMS interception. For AAL2 (medium assurance) SMS OTP is acceptable on its own. For AAL3 (high assurance, e.g. health records, government services) NIST does not allow SMS OTP.

The practical consequence: for consumer banking and e-commerce SMS OTP is standard. For critical applications (cybersecurity, government portals) you need TOTP or a passkey.

PSD2 SCA (Strong Customer Authentication)

For payments in the EU, PSD2 requires strong authentication based on two of three factors: knowledge (password), possession (phone), inherence (biometrics).

SMS OTP alone does not satisfy PSD2 — it is only one factor (possession). It must be complemented by a second one: typically a password or PIN when initiating the payment + OTP to confirm it = knowledge + possession combined.

Testing and monitoring

For production, put in place:

Unit tests:

  • Code generation returns a 6-digit numeric string
  • The bcrypt hash is unique for the same code across calls
  • Verifying the correct code returns ok: true
  • Verifying a wrong code increments attempts by 1
  • After 5 failures it returns too-many-attempts regardless of correctness

Integration tests:

  • Rate limit per phone — the 4th request within an hour returns an error
  • Single use — after consumption the same code cannot be used again
  • TTL — after 5 minutes the code can no longer be verified

Monitoring in production:

  • Failed OTP rate per phone: if >50 % of verifications from one phone fail → attack or bot
  • OTP request rate per IP: if >100/hour → block + alert
  • SMS delivery rate: if it drops below 95 % → check with the provider
  • Average verification time: if it suddenly grows → check DB performance

Costs — what it actually costs

For an average application with 10,000 monthly active users and SMS OTP at login, with recipients in Germany, Austria or the rest of the EU at €0.084 per SMS:

  • Login frequency: on average 4× a month per user = 40,000 OTP/month
  • Price via TopSMS: 40,000 × €0.084 = about €3,358/month

With risk-based skipping for known devices (50 % skipped): the cost falls to about €1,679/month.

With a step-up architecture (OTP only for sensitive actions — 20 % of logins): 8,000 OTP a month, about €672/month. Recipients in Czechia and Slovakia are cheaper, €0.036–0.040 per SMS depending on monthly volume (see the pricing).

The costs are significant — which is why a smart architecture (risk-based + step-up) is key to scalability.

Conclusion

OTP via SMS is still, in 2026, the foundation of the identity infrastructure for most B2C and B2B applications. The implementation is not rocket science, but the details decide. Focus on:

  1. Hash + cryptographic randomness + bcrypt (basic security)
  2. 3-layer rate limit (phone + IP + attempts)
  3. Single-use enforcement + TTL by purpose
  4. Frontend with autocomplete="one-time-code" (UX)
  5. Monitoring the failed rate (attack detection)
  6. Risk-based + step-up for cost efficiency

And if your application has higher-value accounts, keep SMS as the base layer and add TOTP or a passkey for sensitive actions (the chapter “SMS vs TOTP vs passkey” above).

Questions about the implementation? Write to us at info@topsms.cz; for serious projects we are happy to advise on the architecture free of charge.

What next

  • Verify API: ready-made generation and verification of the code, limits enforced by the gateway
  • REST API: sending your own code via POST /api/sms/send, webhooks with delivery reports
  • How to integrate an SMS API in 5 minutes: account, API key, first SMS
  • Pricing: €0.084 per SMS to Germany, Austria and the EU, 10 free SMS to test with