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:
- Onboarding nových uživatelů (registrace, ověření telefonu) — nepotřebuje žádnou aplikaci, funguje univerzálně
- Account recovery — fallback pro případ, že uživatel ztratí přístup k TOTP
- Transakční potvrzení — platby, převody, změny vysoké hodnoty
- Webové aplikace bez mobilního klienta — interní firemní systémy, B2B portály
- 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élka | Kombinací | Brute-force při 1 pokusu/sec | Vhodné pro |
|---|---|---|---|
| 4 čísla | 10 000 | ~1,5 hodiny | Nedoporučuje se |
| 5 čísel | 100 000 | ~14 hodin | Low-risk consumer |
| 6 čísel | 1 000 000 | ~6 dní | Standard pro consumer + banking |
| 8 čísel | 100 milionů | ~3 roky | High-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()nebocrypto.randomBytes() - PHP:
random_int()neborandom_bytes() - Python:
secrets.randbelow()nebosecrets.token_hex()
Nikdy:
Math.random()v JSrand()/mt_rand()v PHPrandom.randint()v Pythonu (používárandommodul, nesecrets)
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
- Plain storage kódu — pokud DB unikne, kódy jsou okamžitě zneužitelné. Vždy hashovat.
Math.random()v JS /rand()v PHP — predictable, prolomitelný za sekundy. Použijte cryptographic random.- Žádný rate limit — bot vyčerpá vaši SMS quotu, faktura jak na palmě. Vždy tři vrstvy (phone, IP, attempts).
- TTL větší než 10 minut — okno pro útok roste exponenciálně. Pro běžné účely 2–5 min, pro recovery max 10 min.
- 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.
- Logování plaintextu kódu — i v error logech. Pravidelně auditujte log výstupy. Logujte jen hash nebo poslední 2 číslice.
- Žá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ší.
- Nestopovat consumption — bez
consumed_atchecks může uživatel (nebo útočník) použít stejný kód víckrát. - Neukončit po max attempts — pokud uživatel 100× chybně zadá, nakonec to možná trefí. Po 5 chybách invalidujte.
- Žá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ýší
attemptso 1 - Po 5 chybách vrátí
too-many-attemptsnezá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:
- Hash + cryptographic random + bcrypt (basic security)
- 3-vrstvý rate limit (phone + IP + attempts)
- Single-use enforcement + TTL podle účelu
- Frontend s
autocomplete="one-time-code"(UX) - Monitoring failed-rate (detekce útoků)
- 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.