← Zpět na blog
15. května 2026·15 min čtení·Tým TopSMS

OTP přes SMS: bezpečná implementace s příklady

Kompletní průvodce implementací OTP přes SMS: generování, hashování, rate limiting, TTL, code příklady v PHP, Node.js a Pythonu.

Většina e-shopů, SaaS aplikací a bankovních systémů v Česku používá OTP přes SMS jako základní mechanismus pro dvoufaktorovou autentizaci (2FA), ověření změny hesla nebo potvrzení platby. Implementačně to vypadá jednoduše — vygenerovat šestici čísel, poslat SMS, ověřit zpět — ale skutečná produkční implementace má spoustu detailů, které rozhodují o tom, zda váš systém odolá brute-force útoku, SIM-swap incidentu nebo masivní spam vlně přes vaši SMS bránu.

Tenhle článek je praktický návod: ne marketingový přehled "co je OTP", ale konkrétní implementace s working code examples v PHP, Node.js a Pythonu, pokrývající všechny security best practices, které byste měli dodržet v produkci.

Proč SMS OTP stále dává smysl v roce 2026

Konkurence pro SMS OTP je dnes silná:

  • TOTP aplikace (Google Authenticator, Authy, 1Password): offline, neovlivnitelné SIM swapem, ale vyžadují, aby si uživatel aplikaci nainstaloval a nastavil — onboarding friction
  • Push notifikace (banky, vlastní mobilní aplikace): nejlepší UX, ale jen pro uživatele s vaší mobilní app
  • WebAuthn / FIDO2 passkeys: bezpečnostně nejsilnější, ale adopce v ČR je stále pod 5 %
  • E-mail OTP: snadný, ale e-mailové schránky bývají kompromitované častěji než telefon

SMS OTP zůstává default volbou pro:

  1. Onboarding nových uživatelů (registrace, ověření telefonu) — nepotřebuje žádnou aplikaci, funguje univerzálně
  2. Account recovery — fallback pro případ, že uživatel ztratí přístup k TOTP
  3. Transakční potvrzení — platby, převody, změny vysoké hodnoty
  4. Webové aplikace bez mobilního klienta — interní firemní systémy, B2B portály
  5. Mass-market consumer apps — kde nemůžete předpokládat technickou zdatnost uživatele

Statistiky z nedávné analýzy DataReportal: 78 % bank a fintech aplikací v EU stále používá SMS OTP jako primární nebo sekundární faktor. Pro většinu B2C aplikací není SMS na ústupu — naopak roste množství "low-friction" 2FA, kde SMS slouží jako jediný faktor pro běžné akce a TOTP/passkey až pro citlivé.

Bezpečnostní principy — co musíte zaručit

Než píšete jediný řádek kódu, musíte mít jasné odpovědi na následujících 7 otázek:

1. Délka a formát kódu

DélkaKombinacíBrute-force při 1 pokusu/secVhodné pro
4 čísla10 000~1,5 hodinyNedoporučuje se
5 čísel100 000~14 hodinLow-risk consumer
6 čísel1 000 000~6 dníStandard pro consumer + banking
8 čísel100 milionů~3 rokyHigh-security (TOTP-grade)

Šestimístný numerický kód je dnešní standard. Nepoužívejte alfanumerické kódy — zhoršují UX (uživatel musí přepínat klávesnici na mobilu) a přínos bezpečnosti je marginální, protože ho vyrovnává rate limiting.

2. TTL (Time To Live) — jak dlouho kód platí

Mýtus: "čím kratší, tím lepší." Pravda: musíte balancovat bezpečnost a UX.

  • 30 sekund: uživatel nestihne přečíst SMS a zadat
  • 1 minuta: pro vysoce citlivé operace (banking payments)
  • 2–5 minut: sweet spot pro většinu use casů
  • 10 minut: pro account recovery, kdy uživatel může mít zhoršený přístup k telefonu
  • Více než 10 minut: zbytečně velké okno pro útok

Doporučení podle účelu:

'login'    → 2 minuty
'register' → 5 minut (uživatel poprvé prochází formulář, dlouhé pole)
'payment'  → 1 minuta (banking, bez výjimky)
'recovery' → 10 minut
'2fa'      → 2 minuty

3. Rate limiting na 3 úrovních

Bez rate limitu si útočník (nebo bot) může dovolit vyhánět vám faktury za SMS přes vaši bránu, vyčerpat váš denní limit a způsobit reálnou škodu. Implementujte všechny tři vrstvy:

Per phone number (proti spamu):

  • Max 3 OTP požadavky za hodinu na stejné číslo
  • Max 5 OTP požadavků za 24 hodin na stejné číslo

Per IP adresa (proti enumeration útokům):

  • Max 10 OTP požadavků za minutu z jedné IP
  • Max 50 za hodinu

Per failed verification (proti brute-force):

  • Max 5 chybných pokusů na jeden vystavený kód
  • Po 5 chybách → invalidate kód, uživatel musí požádat o nový
  • Po 10 chybných pokusech za hodinu na číslo → temporary block 30 minut

4. Hashování — neukládejte kódy v plaintextu

Pokud vám unikne databáze, plaintextové OTP záznamy můžou být zneužity v krátkém okně před expirací. Hashujte je úplně stejně jako hesla:

  • bcrypt (doporučeno, slow hash): pomalý hash zpomalí brute-force
  • argon2id (modernější alternativa): preferován tam, kde dostupný
  • SHA-256 + salt: minimum, rychlý, ale tato rychlost se vrátí na útočníkovi

OTP kódy jsou natolik krátké, že rychlý hash (SHA) by stačil k brute-force celého keyspace během sekund. Použijte bcrypt s nákladem alespoň 10 — verifikace pak trvá ~100 ms, což je z UX perspektivy přijatelné, ale výrazně zpomalí útočníka.

5. Cryptographic random — ne Math.random()

Většina vývojářů zná Math.random() v JavaScriptu nebo rand() v PHP. Nepoužívejte je pro bezpečnostní účely. Tyto generátory jsou predictable a útočník, který zachytí pár vygenerovaných kódů, může predikovat další.

Vždy použijte cryptographic random:

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

Nikdy:

  • Math.random() v JS
  • rand() / mt_rand() v PHP
  • random.randint() v Pythonu (používá random modul, ne secrets)

6. Single-use enforcement

Po úspěšné verifikaci musí být kód okamžitě invalidován (smazán nebo označen jako consumed). Útočník, který by získal kód z paměti uživatele nebo z SMS odposlechu (méně časté ale možné), by ho mohl použít víckrát.

Stejně tak po dosažení maxima failed pokusů kód invalidovat — i v případě, že útočník náhodou trefí na poslední pokus, kódu má bezpečnostní strop.

7. Constant-time comparison

Při verifikaci hashu nikdy nepoužívejte obyčejný == na řetězcích. Většina knihoven (bcrypt.compare, password_verify) má constant-time porovnání built-in. Ručně byste použili crypto.timingSafeEqual() v Node.js, hash_equals() v PHP nebo hmac.compare_digest() v Pythonu.

Databázové schema

Univerzální schema, které pokryje všechny use casy:

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)
);

Pole purpose umožňuje různé TTL a rate-limit politiky pro různé akce. Pole ip a user_agent_hash slouží jako základ pro fraud detection — pokud žádost přišla z prohlížeče A a verifikace z prohlížeče B, je to podezřelé.

Pravidelný cleanup: spusťte cron každou hodinu, který smaže expirované záznamy starší než 24 hodin. Spotřebované záznamy nechte ještě 30 dnů kvůli forenzním stopám.

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));

Implementace v Node.js (s Prismou)

Předpokládejme, že používáte TopSMS REST API pro doručení SMS. Nejprve vygenerování a odeslání:

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_API_KEY}`,
    },
    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 }
}

A verifikace:

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 }
}

Implementace v PHP

Stejná logika v PHP 8+ s 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_API_KEY'),
        ],
        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];
}

Implementace v Pythonu (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_API_KEY = os.environ['TOPSMS_API_KEY']
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_API_KEY}',
        },
        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 — detaily, které rozhodují

Backend máte hotový. Teď frontend:

HTML input s auto-complete

<input
  type="text"
  inputmode="numeric"
  autocomplete="one-time-code"
  pattern="\d{6}"
  maxlength="6"
  placeholder="••••••"
  aria-label="Ověřovací kód"
/>

Atribut autocomplete="one-time-code" umožní iOS a Androidu automaticky nabídnout OTP přečtený z SMS notifikace — uživatel klikne na suggestion a kód se vyplní. Drastické zlepšení konverze.

Countdown timer pro expiraci

Vždy zobrazte uživateli, kolik času mu zbývá. Po vypršení deaktivujte input a nabídněte tlačítko "Poslat znovu". Cooldown na tlačítku Resend = 60 sekund, aby uživatel nespamoval generátor.

Maskování telefonu

Zobrazte uživateli na potvrzovací stránce maskovaný telefon: +420 ••• ••• 884 (jen poslední 3 číslice). Pomůže to uživateli ověřit, že kód jde na správné číslo, ale neukáže celé číslo někomu, kdo by se mu díval přes rameno.

Backup option vždy viditelná

Pod inputem nabízejte "Neobdrželi jste SMS?" → alternativní cesta (e-mail OTP, support kontakt, TOTP backup). 5–8 % uživatelů má problém s doručením SMS (nedostupný operátor, plný telefon, špatné číslo) — bez backup option je ztratíte.

Časté chyby a jak se jim vyhnout

  1. Plain storage kódu — pokud DB unikne, kódy jsou okamžitě zneužitelné. Vždy hashovat.
  2. Math.random() v JS / rand() v PHP — predictable, prolomitelný za sekundy. Použijte cryptographic random.
  3. Žádný rate limit — bot vyčerpá vaši SMS quotu, faktura jak na palmě. Vždy tři vrstvy (phone, IP, attempts).
  4. TTL větší než 10 minut — okno pro útok roste exponenciálně. Pro běžné účely 2–5 min, pro recovery max 10 min.
  5. Sdílení kódu napříč zařízeními — útočník s odposlechem SMS může kód použít na svém zařízení. Session binding: uložte hash session ID nebo IP do OTP záznamu a verifikujte, že verifikace přišla ze stejné session.
  6. Logování plaintextu kódu — i v error logech. Pravidelně auditujte log výstupy. Logujte jen hash nebo poslední 2 číslice.
  7. Žádný throttle per phone — útočník s rotujícími IP a stejným cílovým telefonem může způsobit DoS přes SMS. Striktní per-phone limit (max 3/hod) řeší.
  8. Nestopovat consumption — bez consumed_at checks může uživatel (nebo útočník) použít stejný kód víckrát.
  9. Neukončit po max attempts — pokud uživatel 100× chybně zadá, nakonec to možná trefí. Po 5 chybách invalidujte.
  10. Žádný cleanup expirovaných záznamů — tabulka roste do nekonečna, queries zpomalí. Cron každou hodinu.

Pokročilé techniky pro production-grade

Risk-based authentication

Nepoužívejte OTP pro každý login. Pokud uživatel přihlašuje z známého zařízení a IP (jste schopni detekovat přes cookie + IP fingerprint), přeskočte OTP. Vyžádejte ho jen při:

  • Přihlášení z nové geo-lokace
  • Přihlášení z nového zařízení (žádný "remember me" cookie)
  • Akce s vysokou hodnotou (platba, smazání účtu, změna hesla, změna 2FA)

Vyšší konverze + nižší náklady na SMS + lepší UX.

Step-up authentication

Místo binárního "máš/nemáš OTP" model implementujte několik úrovní:

  • Úroveň 0 — žádné OTP, jen heslo. Pro nízkohodnotné akce (čtení účtu, drobné nákupy).
  • Úroveň 1 — SMS OTP. Pro středně hodnotné akce (přidání produktu do košíku za 5000+ Kč).
  • Úroveň 2 — SMS OTP + TOTP, nebo passkey. Pro vysokohodnotné akce (platba, změna hesla, export dat).

Backup codes

Vygenerujte uživateli 10 jednorázových backup kódů, které si může vytisknout nebo uložit do password manageru. Pokud nemá přístup k telefonu (ztrátu, krádež, výpadek operátora), může se přihlásit pomocí jednoho z backup kódů. Implementačně podobné OTP, jen TTL je trvalá (do spotřeby) a generování proběhne jednou.

Detekce SIM swap

SIM swap útok = útočník přesvědčí operátora, aby přepsal telefonní číslo na novou SIM kartu. Detekovat to lze přes:

  • API operátora (T-Mobile, O2 mají pro velké klienty SIM-swap notification API)
  • Změna behaviour patternu — uživatel se zničehonic přihlašuje z jiné geo-lokace, jiného zařízení a žádá o OTP
  • Velmi krátký čas mezi password reset a první přihlášení z nového zařízení

Pokud detekujete SIM swap riziko, donucujte uživatele k vyšší úrovni ověření (TOTP, biometrika) nebo dočasně omezte vysokohodnotné akce.

Compliance — GDPR, PSD2, NIST

GDPR

Telefonní číslo je osobní údaj ve smyslu GDPR čl. 4. Pro zpracování:

  • Právní základ: čl. 6 odst. 1 písm. b (plnění smlouvy) — protože OTP slouží k zabezpečení účtu, který je předmětem smlouvy s uživatelem
  • Retence: smazat spotřebované záznamy po 30 dnech, nepoužité po 24 hodinách
  • Informování: v zásadách ochrany osobních údajů uvést, že telefonní číslo a SMS obsah jsou zpracovávány

NIST 800-63B — Authenticator Assurance Levels

NIST (americký standard, ale často referencovaný i v EU) klasifikuje SMS OTP jako "restricted authenticator" — méně bezpečný než TOTP nebo FIDO2 kvůli riziku SIM swapu a SMS interceptu. Pro AAL2 (medium assurance) je SMS OTP přijatelná samostatně. Pro AAL3 (high assurance, např. health records, government services) NIST SMS OTP nepovoluje.

Praktický důsledek: pro consumer banking a e-commerce je SMS OTP standardní. Pro kritické aplikace (kybernetická bezpečnost, vládní portály) potřebujete TOTP nebo passkey.

PSD2 SCA (Strong Customer Authentication)

Pro platby v EU vyžaduje PSD2 silnou autentizaci založenou na dvou ze tří faktorů: knowledge (heslo), possession (telefon), inherence (biometrika).

SMS OTP samostatně nesplňuje PSD2 — je to jen jeden faktor (possession). Musí být doplněna druhým: typicky heslo nebo PIN při zahájení platby + OTP pro potvrzení = combined knowledge + possession.

Testování a monitoring

Pro produkci nasaďte:

Unit testy:

  • Vygenerování kódu vrátí 6-číselný numerický řetězec
  • Bcrypt hash je unique pro stejný kód při různých voláních
  • Verifikace správného kódu vrátí ok: true
  • Verifikace nesprávného kódu zvýší attempts o 1
  • Po 5 chybách vrátí too-many-attempts nezávisle na správnosti

Integration testy:

  • Rate limit per phone — 4. žádost za hodinu vrátí error
  • Single-use — po consume nelze stejný kód použít znovu
  • TTL — po 5 minutách nelze code už ověřit

Monitoring v produkci:

  • Failed OTP rate per phone: pokud >50 % verifikací z jednoho telefonu selhává → útok nebo bot
  • OTP request rate per IP: pokud >100/hod → block + alert
  • SMS delivery rate: pokud klesne pod 95 % → check s providerem
  • Average verification time: pokud najednou roste → zkontrolovat výkon DB

Náklady — kolik to fakticky stojí

Pro průměrnou aplikaci s 10 000 aktivními uživateli měsíčně a SMS OTP při loginu:

  • Login frequency: průměrně 4× měsíčně na uživatele = 40 000 OTP/měsíc
  • Cena přes TopSMS (Pro tarif): 40 000 × 0,88 Kč = 35 200 Kč/měsíc

S risk-based skipping pro known devices (50 % skip): cena spadne na ~17 600 Kč/měsíc.

S step-up architecture (OTP jen pro citlivé akce — 20 % loginů): cena spadne na ~7 000 Kč/měsíc.

Náklady jsou významné — proto je smart architecture (risk-based + step-up) klíčová pro škálovatelnost.

Závěr

OTP přes SMS je v roce 2026 stále základ identity infrastructure pro většinu B2C a B2B aplikací v Česku. Implementačně to není raketová věda, ale detaily rozhodují. Zaměřte se na:

  1. Hash + cryptographic random + bcrypt (basic security)
  2. 3-vrstvý rate limit (phone + IP + attempts)
  3. Single-use enforcement + TTL podle účelu
  4. Frontend s autocomplete="one-time-code" (UX)
  5. Monitoring failed-rate (detekce útoků)
  6. Risk-based + step-up pro nákladovou efektivitu

Pokud implementujete OTP pro produkční aplikaci a chcete spolehlivé doručení do CZ/SK pod 2 sekundy s garantovanou 99% doručitelností přes přímé propojení s operátory, TopSMS REST API je k tomu navržené. Sandbox prostředí + 10 SMS zdarma na vyzkoušení, registrace v jedné minutě. JSON formát, Bearer token autentizace, webhook delivery reporty pro real-time tracking statusu doručení.

Otázky k implementaci? Napište nám na info@topsms.cz — pro vážné projekty rádi poradíme s architekturou zdarma.

otp2fabezpečnostrest-apiimplementace
Otestujte TopSMS zdarma

10 SMS na start, bez závazku, registrace minuta.

Registrovat zdarma →