SynthBit Logo
Vývoj10 min

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.

👤Andrej
📅
SMS ověření formuláře: OTP, GDPR a spolehlivost v praxi

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

  1. 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>
  1. Resend + fallback – odpočet 30–60 s, „Změnit číslo“, fallback hovor/e-mail, po 3 resend pokusech nabídni pauzu.
  2. Chybové stavy s aria-live – INVALID_CODE, EXPIRED, TOO_MANY_ATTEMPTS, DELIVERY_FAILED, RATE_LIMIT.
  3. A11y a mobilní ergonomie – label ≠ placeholder, kontrast AA, target ≥ 44×44 px, zachovej focus ring.
  4. Web OTP / autofillautocomplete="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

  1. Klient pošle phone (E.164) + intent + requestId.
  2. API normalizuje, aplikuje limity (per phone/IP), generuje kód, uloží hash + TTL, pošle SMS, vrátí maskované číslo a cooldown.
  3. Webhook z gateway vrací delivery status.

Tok VERIFY

  1. Klient pošle code + intent (telefon drž v session).
  2. 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: requestId a kontrola duplicit; retry-safe volání SMS gateway; webhook /api/otp/status s 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řebujete nastavit OTP nebo auditovat flow?

Pomůžeme s UX, bezpečností, GDPR i monitoringem doručitelnosti.