SMS ověření formuláře: OTP, GDPR a spolehlivost v praxi
Jak navrhnout a naprogramovat SMS OTP ve formulářích tak, aby bylo rychlé, přístupné, bezpečné a v souladu s GDPR.

SMS ověření formuláře: OTP, GDPR a spolehlivost v praxi
SMS OTP je rychlý a známý způsob ověření, ale snadno zlomí konverze, když se udělá bez UX, limitů a GDPR. Níže najdeš kompletní postup: kdy SMS použít, jak ji navrhnout, implementovat v Next.js i WordPressu, zabezpečit, měřit a mít papíry v pořádku.
Kdy dává SMS verifikace smysl
- Registrace, passwordless login, step-up 2FA při riziku (nové zařízení/IP).
- E-shop: vysoká hodnota objednávky, digitální zboží, změna telefonu/adresy/karty.
- E-forms (gov/health/školství): potvrzení žadatele, změny citlivých údajů, fallback když není eID/BankID.
- Reset hesla, odemčení účtu, přístup k výpisům, změna oprávnění v B2B portálech.
Shrnutí: SMS je komfortní pro střední riziko. Není to nejbezpečnější 2FA (SIM-swap), proto přidej limity, expirace a fallbacky.
Kdy raději ne (nebo jen výjimečně)
- Nízká hodnota akce (newsletter opt-in) → raději double opt-in e-mailem.
- Trhy s nízkou doručitelností/roamingem nebo vysokými náklady na SMS.
- Nucení ke sdílení telefonu bez jasného důvodu → horší konverze a důvěra.
Alternativy a mini rozhodovací matice
- Passkeys/WebAuthn, TOTP, e-mail magic link, push notifikace.
- Risk-based: důvěryhodná zařízení = e-mail, rizikové kroky = SMS/TOTP/passkey.
- Fallback: hovor s kódem nebo e-mailový link.
| Situace | Riziko | Doporučení | | --- | --- | --- | | Registrace B2C | Střední | SMS OTP nebo e-mail link; step-up při podezření | | Reset hesla / změna telefonu | Vysoké | SMS + druhý kanál (TOTP/passkey/e-mail) | | E-shop > X Kč / digitální zboží | Středně-vysoké | Risk-based SMS, limity pokusů | | Veřejná správa – e-formulář | Vysoké | Preferuj eID; SMS jako komfort/fallback | | Newsletter | Nízké | Bez SMS, stačí e-mail double opt-in |
Compliance a data (tl;dr)
- Právní základ: plnění smlouvy / oprávněný zájem; transakční SMS ≠ marketing (to chce souhlas).
- Minimalizace: ukládej hash OTP s TTL 5–10 min; logy 30–90 dní; maskuj číslo.
- DPA se SMS bránou, SCC pokud data jdou mimo EHP; ROPA položka „OTP verifikace“, DPIA pro veřejnou správu/health.
- Soukromí by design: žádné OTP v URL/logu, audit bez PII (hash IP/UA, intent, traceId). Fallback bez bariér.
UX vzory, které nezabíjejí konverze
- OTP pole: 1× input (doporučeno) – auto-format, funguje copy/paste i autofill.
Pokud 6 boxů: auto-advance, backspace dozadu, paste rozdělí kód, aria-label pro každou pozici.
<input
type="text"
inputmode="numeric"
autocomplete="one-time-code"
pattern="[0-9]*"
aria-describedby="otp-help"
/>
<small id="otp-help">Zadejte 6místný kód, poslali jsme ho na vaše číslo.</small>
- Resend + fallback – odpočet 30–60 s, „Změnit číslo“, fallback hovor/e-mail, po 3 resend pokusech nabídni pauzu.
- Chybové stavy s aria-live – INVALID_CODE, EXPIRED, TOO_MANY_ATTEMPTS, DELIVERY_FAILED, RATE_LIMIT.
- A11y a mobilní ergonomie – label ≠ placeholder, kontrast AA, target ≥ 44×44 px, zachovej focus ring.
- Web OTP / autofill –
autocomplete="one-time-code", v SMS textu doména a čistý kód.
Architektura řešení (referenční)
Uživatel (web/app)
→ POST /api/otp/send, /api/otp/verify
→ API vrstvy (Next.js / WP REST) – validace, rate-limit, audit log
→ SMS gateway (Twilio/Vonage/Sinch/EU) + webhook /api/otp/status (HMAC)
→ Datová vrstva (Redis/DB) – OTP hash+TTL, čítače pokusů, audit
Tok SEND
- Klient pošle phone (E.164) + intent + requestId.
- API normalizuje, aplikuje limity (per phone/IP), generuje kód, uloží hash + TTL, pošle SMS, vrátí maskované číslo a cooldown.
- Webhook z gateway vrací delivery status.
Tok VERIFY
- Klient pošle code + intent (telefon drž v session).
- API ověří existenci, expiraci a pokusy, porovná hash (timing-safe), při úspěchu smaže OTP a označí uživatele ověřeného.
Implementace v Next.js (App Router)
Komponenta OTP (1× input)
// components/otp/SingleInputOTP.tsx
"use client";
import { useEffect, useRef, useState } from "react";
type Props = { phoneMasked: string; length?: number; onVerified?: () => void };
export default function SingleInputOTP({ phoneMasked, length = 6, onVerified }: Props) {
const [code, setCode] = useState("");
const [error, setError] = useState<string | null>(null);
const [resendIn, setResendIn] = useState<number>(30);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (resendIn <= 0) return;
const t = setInterval(() => setResendIn((s) => s - 1), 1000);
return () => clearInterval(t);
}, [resendIn]);
const normalize = (v: string) => v.replace(/\D/g, "").slice(0, length);
const verify = async (val: string) => {
if (val.length !== length) return;
setLoading(true);
try {
const res = await fetch("/api/otp/verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ code: val }),
});
const json = await res.json();
if (!res.ok || !json.ok) {
setError(mapError(json.reason));
setCode("");
inputRef.current?.focus();
} else {
onVerified?.();
}
} catch {
setError("Něco se pokazilo. Zkuste to prosím znovu.");
} finally {
setLoading(false);
}
};
const resend = async () => {
if (resendIn > 0) return;
setError(null);
const res = await fetch("/api/otp/send", { method: "POST" });
const json = await res.json();
if (json.ok) setResendIn(json.resend_after_sec ?? 30);
else setError(mapError(json.reason));
};
useEffect(() => {
if (code.length === length) verify(code);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [code]);
return (
<div aria-live="polite">
<label htmlFor="otp" className="block text-sm font-medium">SMS kód</label>
<input
ref={inputRef}
id="otp"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]*"
className="mt-1 w-full rounded border px-3 py-2"
placeholder="••••••"
value={code}
onChange={(e) => {
setCode(normalize(e.target.value));
setError(null);
}}
aria-describedby="otp-help"
aria-invalid={!!error}
/>
<p id="otp-help" className="mt-1 text-sm text-neutral-500">
Poslali jsme 6místný kód na {phoneMasked}.
</p>
{error && <p className="mt-2 text-sm text-red-600" role="status">{error}</p>}
<div className="mt-3 flex items-center gap-3">
<button
type="button"
onClick={() => verify(code)}
className="rounded bg-black px-4 py-2 text-white disabled:opacity-60"
disabled={loading || code.length !== length}
>
Potvrdit ručně
</button>
<button type="button" onClick={resend} className="underline underline-offset-4">
{resendIn > 0 ? `Znovu poslat za ${resendIn}s` : "Poslat kód znovu"}
</button>
<a href="#" className="text-sm underline">Změnit číslo</a>
</div>
</div>
);
}
function mapError(reason?: string) {
switch (reason) {
case "INVALID_CODE": return "Kód nesouhlasí. Zkuste to prosím znovu.";
case "EXPIRED": return "Kód vypršel. Pošleme nový.";
case "TOO_MANY_ATTEMPTS": return "Příliš mnoho pokusů. Krátká pauza z bezpečnostních důvodů.";
case "DELIVERY_FAILED": return "SMS se nedaří doručit. Zvolte hovor nebo změňte číslo.";
case "RATE_LIMIT":
case "COOLDOWN": return "Příliš časté pokusy. Zkuste to prosím za chvíli.";
default: return "Něco se pokazilo. Zkuste to prosím znovu.";
}
}
API route /api/otp/send + /api/otp/verify (zkráceno)
Hash OTP (SHA-256 + salt), Redis/Upstash TTL, Zod validace, vícevrstvý rate-limit (IP + phone), requestId pro idempotenci, HMAC webhook pro delivery.
// app/api/otp/send/route.ts
import { NextResponse } from "next/server";
import { kv, ttl } from "@/lib/redis";
import { z } from "zod";
import crypto from "crypto";
const schema = z.object({
phone: z.string().optional(), // v praxi drž v session
intent: z.enum(["register","checkout","reset_password"]).optional()
});
export async function POST(req: Request) {
try {
const ip = req.headers.get("x-forwarded-for") ?? "unknown";
const body = await req.json().catch(() => ({}));
const { phone = "", intent = "register" } = schema.parse(body);
// rate-limit
const rlIpKey = `otp:rl:ip:${ip}`;
const ipCount = (await kv.incr(rlIpKey)) ?? 0;
if (ipCount === 1) await kv.expire(rlIpKey, 600);
if (ipCount > 10) return err("RATE_LIMIT");
const key = `otp:${intent}:${phone}`;
const meta = await kv.hgetall<Record<string,string>>(key);
const now = Date.now();
if (meta?.last_sent_at && now - Number(meta.last_sent_at) < ttl.cooldownMs) {
return err("COOLDOWN");
}
const code = String(Math.floor(100000 + Math.random() * 900000));
const hash = crypto.createHash("sha256").update(code + process.env.OTP_SALT!).digest("hex");
await kv.hset(key, {
code_hash: hash,
attempts: "0",
resend_count: String((Number(meta?.resend_count) || 0) + 1),
last_sent_at: String(now),
expires_at: String(now + ttl.otpSec * 1000),
});
await kv.expire(key, ttl.otpSec);
await sendSms(phone, `${code} je váš ověřovací kód pro https://domena.cz`);
return NextResponse.json({ ok: true, masked: maskPhone(phone), resend_after_sec: 30 });
} catch {
return err("SERVER_ERROR", 500);
}
}
function err(reason: string, status = 429) {
return NextResponse.json({ ok: false, reason }, { status });
}
// app/api/otp/verify/route.ts
import { NextResponse } from "next/server";
import { kv, ttl } from "@/lib/redis";
import crypto from "crypto";
export async function POST(req: Request) {
const { code } = await req.json();
if (!code) return err("INVALID_CODE", 400);
const phone = await getPhoneFromSession(req);
const intent = "register";
const key = `otp:${intent}:${phone}`;
const meta = await kv.hgetall<Record<string,string>>(key);
if (!meta) return err("NOT_FOUND", 400);
const now = Date.now();
if (now > Number(meta.expires_at)) {
await kv.del(key);
return err("EXPIRED", 400);
}
const attempts = Number(meta.attempts || 0);
if (attempts >= 5) {
await kv.pexpire(key, ttl.lockMs);
return err("TOO_MANY_ATTEMPTS");
}
const expected = meta.code_hash;
const actual = crypto.createHash("sha256").update(code + process.env.OTP_SALT!).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(expected));
if (!ok) {
await kv.hincrby(key, "attempts", 1);
return err("INVALID_CODE", 400);
}
await kv.del(key);
return NextResponse.json({ ok: true });
}
Validace, limity, testy, telemetry (Next.js)
- Limity: send 10/10 min (IP), 3/10 min (phone), cooldown 30–60 s; verify max 5 pokusů → lock 10 min.
- Idempotence:
requestIda kontrola duplicit; retry-safe volání SMS gateway; webhook/api/otp/statuss HMAC. - Testy: unit (hash, TTL, limity), e2e flow send → verify → redirect; chybové stavy
INVALID_CODE,EXPIRED,TOO_MANY_ATTEMPTS,RATE_LIMIT,DELIVERY_FAILED. - Telemetry: eventy
otp_view,otp_send_clicked,otp_send_success,otp_verify_success,otp_verify_fail,otp_resend,otp_timeout; dashboard completion rate, median/p95 doby, resend rate, error mix, delivery success; alerty <95 % doručitelnost nebo >60 s p95.
Implementace ve WordPressu (REST + transients)
Mu-plugin: /wp-content/mu-plugins/otp.php (zkráceno)
<?php
/**
* Plugin Name: OTP REST (minimal)
*/
add_action('rest_api_init', function () {
register_rest_route('otp/v1', '/send', [
'methods' => 'POST',
'permission_callback' => '__return_true',
'callback' => 'otp_rest_send',
]);
register_rest_route('otp/v1', '/verify', [
'methods' => 'POST',
'permission_callback' => '__return_true',
'callback' => 'otp_rest_verify',
]);
});
function otp_normalize_phone($phone) {
$p = preg_replace('/\D+/', '', $phone ?? '');
if (strpos($p, '00') === 0) $p = substr($p, 2);
if ($p && $p[0] !== '+') $p = '+' . $p;
return $p;
}
function otp_hash($code) { return hash('sha256', $code . AUTH_SALT); }
function otp_store_key($intent, $phone) { return 'otp_' . md5($intent . '|' . $phone); }
function otp_rest_send(\WP_REST_Request $req) {
nocache_headers();
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$body = $req->get_json_params();
$phone = otp_normalize_phone($body['phone'] ?? '');
$intent= sanitize_key($body['intent'] ?? 'register');
if (!$phone) return new \WP_REST_Response(['ok'=>false,'reason'=>'PHONE_REQUIRED'], 400);
$rl_key = 'otp_rl_' . md5($phone . '|' . $ip);
$rl = (int) get_transient($rl_key);
if ($rl >= 10) return new \WP_REST_Response(['ok'=>false,'reason'=>'RATE_LIMIT'], 429);
set_transient($rl_key, $rl + 1, 10 * MINUTE_IN_SECONDS);
$store_key = otp_store_key($intent, $phone);
$meta = get_transient($store_key);
$now = time();
if (is_array($meta) && isset($meta['last_sent_at']) && ($now - (int)$meta['last_sent_at']) < 30) {
return new \WP_REST_Response(['ok'=>false,'reason'=>'COOLDOWN'], 429);
}
$code = (string) wp_rand(100000, 999999);
$data = [
'code_hash' => otp_hash($code),
'attempts' => 0,
'resend_count'=> isset($meta['resend_count']) ? (int)$meta['resend_count'] + 1 : 1,
'last_sent_at'=> $now,
'expires_at' => $now + (10 * MINUTE_IN_SECONDS),
];
set_transient($store_key, $data, 10 * MINUTE_IN_SECONDS);
// sms_send($phone, "$code je váš ověřovací kód pro " . home_url());
$masked = substr($phone, 0, 4) . '***' . substr($phone, -2);
return new \WP_REST_Response(['ok'=>true, 'masked'=>$masked, 'resend_after_sec'=>30], 200);
}
function otp_rest_verify(\WP_REST_Request $req) {
nocache_headers();
$body = $req->get_json_params();
$phone = otp_normalize_phone($body['phone'] ?? '');
$intent= sanitize_key($body['intent'] ?? 'register');
$code = preg_replace('/\D+/', '', $body['code'] ?? '');
if (!$phone || !$code) return new \WP_REST_Response(['ok'=>false,'reason'=>'INVALID_CODE'], 400);
$store_key = otp_store_key($intent, $phone);
$meta = get_transient($store_key);
if (!is_array($meta)) return new \WP_REST_Response(['ok'=>false,'reason'=>'NOT_FOUND'], 400);
$now = time();
if ($now > (int)$meta['expires_at']) {
delete_transient($store_key);
return new \WP_REST_Response(['ok'=>false,'reason'=>'EXPIRED'], 400);
}
if ((int)($meta['attempts'] ?? 0) >= 5) {
set_transient($store_key, $meta, 10 * MINUTE_IN_SECONDS);
return new \WP_REST_Response(['ok'=>false,'reason'=>'TOO_MANY_ATTEMPTS'], 429);
}
$expected = $meta['code_hash'];
$actual = otp_hash($code);
$ok = function_exists('hash_equals') ? hash_equals($expected, $actual) : ($expected === $actual);
if (!$ok) {
$meta['attempts'] = (int)$meta['attempts'] + 1;
$ttl = max(60, (int)$meta['expires_at'] - $now);
set_transient($store_key, $meta, $ttl);
return new \WP_REST_Response(['ok'=>false,'reason'=>'INVALID_CODE'], 400);
}
delete_transient($store_key);
return new \WP_REST_Response(['ok'=>true], 200);
}
WP poznámky: REST nonce (X-WP-Nonce) pro přihlášené, nocache headers na /wp-json/otp/*, stejné chybové kódy jako v UI, maskuj číslo, hooky pro logování bez PII.
Bezpečnost: bruteforce, audit, minimalizace
- Vícevrstvý rate-limit: IP + phone + userId + globální; exponenciální pauza po 3–5 chybách, ale ponech „Změnit číslo“ a fallback.
- Enumerace: jednotné odpovědi; maskuj čísla (+420 777***34).
- HMAC webhooky z SMS gateway, časové okno ±5 min; requestId pro idempotenci.
- Audit log (append-only, bez kódu): otp_send_request/success/fail, otp_verify_success/fail, otp_locked, otp_resend; pole: čas, intent, actor, hash IP/UA, traceId; retenční doba 30–90 dní.
- Šifrování at-rest (telefon), role-based přístup (least privilege), zálohy+WORM dle rizikovosti.
GDPR & dokumentace (ROPA/DPIA)
- Právní základy: plnění smlouvy (registrace/přihlášení, dokončení objednávky), oprávněný zájem (anti-fraud) – dolož LIA; veřejný sektor dle zákonného zmocnění.
- Retence: OTP hash 5–10 min, lock/cooldown 10–60 min, audit 30–90 dní, doručovací logy 30 dní.
- Transparentnost v UI: „Číslo používáme jen k ověření a ochraně proti zneužití. Kód platí 10 min. Neposíláme marketingové SMS.“
- Vzor odstavce do zásad: ověření čísla (účel, právní základ b/f, rozsah E.164 + technické události, doba uložení, příjemci – SMS gateway na základě DPA, práva subjektů).
- DPIA: veřejná správa/health/rizikové scénáře; popis zpracování, proporcionalita, rizika (únik čísla, zneužití, nedoručení), opatření (hash, TTL, rate-limit, šifrování, audit, fallback), reziduální riziko.
- ROPA položka „OTP verifikace“: účel, kategorie údajů, subjekty, příjemci, retence, bezpečnost, přenosy, kontakty.
Měření, A/B test a alerty
Metriky: completion rate (otp_verify_success/otp_view), CTR send, resend rate, median/p95 doby, error mix, delivery success, rate-limit incidence.
Event schéma: otp_view, otp_send_clicked, otp_send_success{provider,resend}, otp_send_rate_limited, otp_delivery_failed, otp_code_entered, otp_verify_success{attempts}, otp_verify_fail{reason}, otp_resend{cooldown}, otp_change_phone, otp_timeout (vše s otp_session_id).
Funnel: view → send_clicked → send_success → code_entered → verify_success; sleduj drop-off a „dead ends“.
A/B test: hypotéza např. „1× input + lepší mikrocopy zvýší completion o +3 p. b.“; randomizace na otp_session_id; guardrails (rate_limit spike, delivery_failed spike).
Alerty: delivery success <95 % (1 h), median >60 s (1 h), spike RATE_LIMIT/DELIVERY_FAILED > +50 % vs. 7denní průměr.
Checklist ke stažení (nasazení → testy → monitoring → incident plan)
Rychlý přehled inline:
- Příprava & právní rámec (účel, právní základ, DPA/SCCs, ROPA, DPIA, výběr gateway).
- Backend: OTP hash+TTL, /send + /verify + webhook HMAC, limity, idempotence, audit bez PII.
- Frontend: 1× input nebo 6× boxy s auto-advance/backspace/paste,
inputmode="numeric",autocomplete="one-time-code", resend 30–60 s, fallbacky, A11y. - Testy: happy path + chyby, lock, resend cooldown, validace webhooku.
- Měření: funnel eventy,
otp_session_id, median/p95 doby, alerty. - Bezpečnost & provoz: šifrování, role, adaptivní CAPTCHA, zálohy, incident plan.
- Go-live: feature flag/A-B, support FAQ, post-mortem po 2 týdnech.
Plný checklist ke stažení: OTP checklist (MD soubor, připravený na Jiru/Confluence).
CTA a interní prolinkování
- Potřebuješ návrh, audit nebo implementaci? Ozvi se přes kontakt.
- Pro veřejnou správu: WCAG a e-forms audit.
- Bezpečnost a API delivery: Vývoj a API bezpečnost.
Potřebujete nastavit OTP nebo auditovat flow?
Pomůžeme s UX, bezpečností, GDPR i monitoringem doručitelnosti.