← Alle Artikel

OTP per SMS: sichere Implementierung, und wann TOTP oder Passkey die bessere Wahl ist

22 Min. LesezeitEnglish

Einmalcodes per SMS implementieren: Erzeugung, Hashing, Rate-Limiting, TTL, Code in PHP, Node.js und Python. Dazu SMS vs. TOTP vs. Passkey, SIM-Swap und NIST.

Die meisten Onlineshops, SaaS-Anwendungen und Bankensysteme nutzen OTP per SMS als grundlegenden Mechanismus für die Zwei-Faktor-Authentifizierung (2FA), die Bestätigung einer Passwortänderung oder die Freigabe einer Zahlung. Die Implementierung sieht einfach aus: sechs Ziffern erzeugen, SMS senden, Eingabe prüfen. Eine echte Produktivimplementierung hat jedoch viele Details, die darüber entscheiden, ob Ihr System einem Brute-Force-Angriff, einem SIM-Swap oder einer massiven Spamwelle über Ihr SMS-Gateway standhält.

Dieser Artikel ist eine praktische Anleitung: kein Marketingüberblick „Was ist OTP“, sondern eine konkrete Implementierung mit lauffähigen Codebeispielen in PHP, Node.js und Python, die alle Sicherheits-Best-Practices abdeckt, die Sie im Produktivbetrieb einhalten sollten.

Sie möchten das nicht selbst bauen? Nutzen Sie die Verify API. TopSMS hat OTP fertig: POST /api/verify/send erzeugt und sendet den Code, POST /api/verify/check prüft ihn. Sie erzeugen und speichern den Code nicht selbst (das Gateway hält nur einen HMAC-Fingerabdruck), und die Limits überwacht das Gateway: 5 Versuche pro Code, höchstens 5 Codes pro Stunde an eine Nummer und 30 s Abstand zwischen zwei Codes. Mit einem Testschlüssel wird nichts gesendet und nichts berechnet, der Code lautet immer 123456. Der Rest dieses Artikels ist für den Fall gedacht, dass Sie OTP vollständig in eigenem Code kontrollieren möchten.

Warum SMS-OTP 2026 weiterhin sinnvoll ist

Die Konkurrenz für SMS-OTP ist heute stark:

  • TOTP-Apps (Google Authenticator, Authy, 1Password): offline, immun gegen SIM-Swap, aber der Nutzer muss eine App installieren und einrichten; das bremst beim Onboarding
  • Push-Benachrichtigungen (Banken, eigene Mobile-App): die beste UX, aber nur für Nutzer mit Ihrer App
  • WebAuthn / FIDO2-Passkeys: sicherheitstechnisch am stärksten, aber bei Endnutzern noch wenig verbreitet
  • E-Mail-OTP: einfach, aber E-Mail-Postfächer werden häufiger kompromittiert als Telefone

SMS-OTP bleibt die Standardwahl für:

  1. Onboarding neuer Nutzer (Registrierung, Telefonverifizierung): braucht keine App, funktioniert universell
  2. Kontowiederherstellung: Fallback, wenn der Nutzer den Zugang zu TOTP verliert
  3. Transaktionsbestätigung: Zahlungen, Überweisungen, Änderungen mit hohem Wert
  4. Webanwendungen ohne Mobile-Client: interne Firmensysteme, B2B-Portale
  5. Consumer-Apps für den Massenmarkt, bei denen Sie keine technische Erfahrung voraussetzen können

Die meisten Banken und Fintech-Apps in der EU nutzen SMS-OTP weiterhin, mindestens als zweiten Faktor. Für die meisten B2C-Anwendungen ist SMS nicht auf dem Rückzug; im Gegenteil nimmt „reibungsarme“ 2FA zu, bei der SMS als einziger Faktor für alltägliche Aktionen dient und TOTP oder Passkey nur für sensible Vorgänge.

SMS vs. TOTP vs. Passkey: Wann reicht welche Methode?

Der zweite Faktor ist immer „etwas, das Sie besitzen“ (Telefon, App, Hardware-Schlüssel). Die Methoden unterscheiden sich darin, wie geprüft wird, dass Sie dieses Gerät tatsächlich in der Hand halten:

MethodeFunktionsweiseHauptschwächeHauptvorteil
SMS-OTPder Server sendet einen sechsstelligen Code per SMSSIM-Swap, Echtzeit-Phishing, Malware, die SMS liestfunktioniert auf jedem Telefon ohne Installation
TOTPeine App (Google Authenticator, Authy, 1Password) erzeugt alle 30 s einen CodeEchtzeit-Phishing, Verlust des Telefons ohne Backup-Codesder Code wandert nie durchs Netz
PushIhre Mobile-App zeigt „Genehmigen“nur für Nutzer mit Ihrer App, „Genehmigungsmüdigkeit“beste UX
Passkey / FIDO2kryptografische Signatur, an die Domain gebunden (Telefon, YubiKey)geringere Verbreitung, aufwendigere Kontowiederherstellungphishing-resistent

Wodurch SMS angreifbar ist

  • SIM-Swap. Der Angreifer überredet den Netzbetreiber (oder einen Mitarbeiter), die Nummer des Opfers auf eine neue SIM-Karte zu übertragen, und empfängt dessen SMS. Das ist selten, aber nicht unmöglich; die Netzbetreiber haben den Prozess verschärft, bei Konten mit hohem Wert sollten Sie dennoch damit rechnen.
  • Echtzeit-Phishing. Eine gefälschte Anmeldeseite fragt Passwort und SMS-Code ab, und der Angreifer verwendet ihn innerhalb von Sekunden. Das ist heute der häufigste Angriff und funktioniert gegen TOTP genauso gut. Resistent ist nur der Passkey, weil die Signatur an die Domain gebunden ist.
  • Malware auf dem Telefon. Banking-Trojaner auf Android lesen eingehende SMS. iOS ist in dieser Hinsicht geschlossener.
  • SS7. Das Signalisierungsprotokoll der Netzbetreiber hat bekannte Schwächen, und ein Angreifer mit Netzzugang kann SMS umleiten. In der Praxis zielen solche Angriffe auf stark exponierte Personen, nicht auf normale Shopkunden.

TOTP umgeht diese Netzangriffe, weil der Code lokal auf dem Telefon entsteht. Der Preis dafür ist das Onboarding: Der Nutzer muss eine App installieren und einen QR-Code scannen, und wer das Telefon ohne Backup-Codes verliert, verliert den Zugang.

Was NIST sagt

NIST SP 800-63B stuft die Verifizierung über das öffentliche Telefonnetz (SMS und Sprachanruf) als eingeschränkten („restricted“) Authentifikator ein. Sie wird nicht verboten, aber der Anbieter soll das Risiko bewerten, die Nutzer darüber informieren und auch eine andere Methode anbieten. Für die Stufe AAL2 ist SMS akzeptabel, für AAL3 (Gesundheitsdaten, Behörden) nicht. Mehr dazu im Kapitel Compliance weiter unten.

Praktische Empfehlung

SMS-OTP reicht für eine gewöhnliche SaaS-Anwendung, einen Onlineshop, ein Kundenportal oder die Registrierung, wenn die Nutzer technisch unterschiedlich erfahren sind und der Verlust des Kontos keinen direkten finanziellen Schaden bedeutet. Beispiele: Telefonverifizierung bei der Registrierung, Bestellbestätigung, Anmeldung im Shop.

Ergänzen Sie TOTP oder Passkey, wenn das Konto erweiterte Rechte hat (Administration, Serverzugang), Sie mit sensiblen Daten arbeiten (Gesundheit, Finanzen, Recht) oder die Regulierung es verlangt. Das sauberste Modell ist Step-up: SMS für alltägliche Aktionen, TOTP oder Passkey für Passwortänderung, Zahlung oder Datenexport (siehe Kapitel Fortgeschrittene Techniken).

Verlangen Sie einen Hardware-Schlüssel (FIDO2) bei Administratoren, Finanzverantwortlichen und überall, wo Compliance (PCI DSS, Bankenregulierung) einen stärkeren Faktor vorschreibt.

Was immer Sie wählen, bieten Sie einen Ersatzweg an: Backup-Codes, eine zweite Methode oder ein verifiziertes Verfahren über den Support. Ein Nutzer ohne Zugriff auf sein Telefon ist sonst ausgesperrt.

Noch ein praktischer Hinweis zur Zustellung: TopSMS schreibt Guthaben nur für eine SMS gut, die das Gateway gar nicht gesendet hat (Fehler auf seiner Seite). Eine gesendete, vom Netzbetreiber aber nicht zugestellte SMS (Telefon aus, ungültige Nummer) wird nicht gutgeschrieben, und ein OTP sollte ohnehin nie blind wiederholt werden; ein neuer Versuch bedeutet einen neuen Code. Das „Erneut senden“ übernimmt Ihre Anwendung, den Zustellstatus erfahren Sie über den Webhook oder per Statusabfrage der Nachricht.

Sicherheitsprinzipien: Was Sie garantieren müssen

Bevor Sie eine einzige Zeile Code schreiben, brauchen Sie klare Antworten auf die folgenden sieben Fragen:

1. Länge und Format des Codes

LängeKombinationenBrute Force bei 1 Versuch/sGeeignet für
4 Ziffern10.000~1,5 StundenNicht empfohlen
5 Ziffern100.000~14 StundenConsumer mit geringem Risiko
6 Ziffern1.000.000~6 TageStandard für Consumer + Banking
8 Ziffern100 Millionen~3 JahreHohe Sicherheit (TOTP-Niveau)

Ein sechsstelliger numerischer Code ist der heutige Standard. Verwenden Sie keine alphanumerischen Codes: Sie verschlechtern die UX (der Nutzer muss am Smartphone die Tastatur umschalten), und der Sicherheitsgewinn ist marginal, weil das Rate-Limiting ihn ohnehin überflüssig macht.

2. TTL (Time To Live): Wie lange der Code gilt

Mythos: „je kürzer, desto besser“. Tatsächlich müssen Sie Sicherheit und UX gegeneinander abwägen.

  • 30 Sekunden: Der Nutzer schafft es nicht, die SMS zu lesen und den Code einzugeben
  • 1 Minute: für hochsensible Vorgänge (Zahlungen im Banking)
  • 2–5 Minuten: der Sweet Spot für die meisten Anwendungsfälle
  • 10 Minuten: für die Kontowiederherstellung, wenn der Nutzer möglicherweise nur eingeschränkten Zugriff auf das Telefon hat
  • Mehr als 10 Minuten: unnötig großes Angriffsfenster

Empfehlung nach Zweck:

'login'    → 2 Minuten
'register' → 5 Minuten (der Nutzer füllt zum ersten Mal ein Formular aus, langes Formular)
'payment'  → 1 Minute (Banking, ohne Ausnahme)
'recovery' → 10 Minuten
'2fa'      → 2 Minuten

3. Rate-Limiting auf 3 Ebenen

Ohne Rate-Limit kann ein Angreifer (oder Bot) über Ihr Gateway Ihre SMS-Kosten in die Höhe treiben, Ihr Tageslimit aufbrauchen und echten Schaden anrichten. Implementieren Sie alle drei Ebenen:

Pro Telefonnummer (gegen Spam):

  • Max. 3 OTP-Anfragen pro Stunde an dieselbe Nummer
  • Max. 5 OTP-Anfragen pro 24 Stunden an dieselbe Nummer

Pro IP-Adresse (gegen Enumeration-Angriffe):

  • Max. 10 OTP-Anfragen pro Minute von einer IP
  • Max. 50 pro Stunde

Pro fehlgeschlagener Verifizierung (gegen Brute Force):

  • Max. 5 Fehlversuche pro ausgestelltem Code
  • Nach 5 Fehlern → Code ungültig machen, der Nutzer muss einen neuen anfordern
  • Nach 10 Fehlversuchen pro Stunde auf eine Nummer → vorübergehende Sperre von 30 Minuten

4. Hashing: Codes nicht im Klartext speichern

Gerät Ihre Datenbank in falsche Hände, können OTP-Einträge im Klartext im kurzen Zeitfenster vor dem Ablauf missbraucht werden. Hashen Sie sie genau wie Passwörter:

  • bcrypt (empfohlen, langsamer Hash): ein langsamer Hash bremst Brute Force
  • argon2id (modernere Alternative): bevorzugt, wo verfügbar
  • SHA-256 + Salt: das Minimum; schnell, aber genau diese Geschwindigkeit kommt dem Angreifer zugute

OTP-Codes sind so kurz, dass sich mit einem schnellen Hash (SHA) der gesamte Schlüsselraum in Sekunden durchprobieren lässt. Verwenden Sie bcrypt mit einem Kostenfaktor von mindestens 10: Die Prüfung dauert dann rund 100 ms, aus UX-Sicht akzeptabel, für den Angreifer aber eine deutliche Bremse.

5. Kryptografischer Zufall, nicht Math.random()

Die meisten Entwickler kennen Math.random() in JavaScript oder rand() in PHP. Verwenden Sie diese Funktionen nicht für Sicherheitszwecke. Diese Generatoren sind vorhersagbar, und ein Angreifer, der ein paar erzeugte Codes abfängt, kann die nächsten vorhersagen.

Verwenden Sie immer eine kryptografische Zufallsquelle:

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

Niemals:

  • Math.random() in JS
  • rand() / mt_rand() in PHP
  • random.randint() in Python (nutzt das Modul random, nicht secrets)

6. Einmalige Verwendung erzwingen

Nach erfolgreicher Verifizierung muss der Code sofort ungültig gemacht werden (gelöscht oder als verbraucht markiert). Ein Angreifer, der den Code durch Social Engineering oder durch Abfangen der SMS erlangt hat (selten, aber möglich), könnte ihn sonst mehrfach verwenden.

Ebenso ist der Code nach Erreichen der maximalen Fehlversuche ungültig zu machen: Selbst wenn der Angreifer beim letzten Versuch zufällig richtig rät, bleibt der Schaden begrenzt.

7. Vergleich in konstanter Zeit

Verwenden Sie beim Prüfen des Hashes niemals ein einfaches == auf Zeichenketten. Die meisten Bibliotheken (bcrypt.compare, password_verify) haben den Vergleich in konstanter Zeit bereits eingebaut. Manuell verwenden Sie crypto.timingSafeEqual() in Node.js, hash_equals() in PHP oder hmac.compare_digest() in Python.

Datenbankschema

Ein universelles Schema, das alle Anwendungsfälle abdeckt:

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

Das Feld purpose ermöglicht unterschiedliche TTL- und Rate-Limit-Regeln für verschiedene Aktionen. Die Felder ip und user_agent_hash sind die Grundlage für die Betrugserkennung: Kam die Anfrage aus Browser A und die Verifizierung aus Browser B, ist das verdächtig.

Regelmäßige Bereinigung: Lassen Sie jede Stunde einen Cron-Job laufen, der abgelaufene Einträge löscht, die älter als 24 Stunden sind. Verbrauchte Einträge behalten Sie für forensische Zwecke noch 30 Tage.

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

Implementierung in Node.js (mit Prisma)

Nehmen wir an, Sie nutzen die REST-API von TopSMS für die Zustellung der SMS. Die Nachrichtentexte, der Absendername MojeFirma und die Fehlermeldungen in den Beispielen sind Platzhalter; ersetzen Sie sie durch Ihren eigenen Text und Ihren freigegebenen Absendernamen. Zuerst Erzeugung und Versand:

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

Und die Verifizierung:

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

Implementierung in PHP

Dieselbe Logik in PHP 8+ mit 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];
}

Implementierung 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: die Details, die entscheiden

Das Backend steht. Jetzt zum Frontend:

HTML-Eingabefeld mit Autovervollständigung

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

Das Attribut autocomplete="one-time-code" erlaubt iOS und Android, den aus der SMS ausgelesenen Code automatisch vorzuschlagen: Der Nutzer tippt auf den Vorschlag, und der Code wird eingetragen. Das verbessert die Konversion erheblich.

Countdown für den Ablauf

Zeigen Sie dem Nutzer immer, wie viel Zeit ihm bleibt. Nach dem Ablauf deaktivieren Sie das Eingabefeld und bieten einen Button „Erneut senden“ an, mit 60 Sekunden Cooldown, damit der Nutzer nicht im Sekundentakt neue Codes anfordert.

Maskierung der Telefonnummer

Zeigen Sie dem Nutzer auf der Bestätigungsseite die maskierte Telefonnummer: +49 ••• ••• 123 (nur die letzten 3 Ziffern). So kann er prüfen, dass der Code an die richtige Nummer geht, ohne dass jemand, der ihm über die Schulter schaut, die vollständige Nummer sieht.

Backup-Option immer sichtbar

Bieten Sie unter dem Eingabefeld „Keine SMS erhalten?“ einen alternativen Weg an (E-Mail-OTP, Support-Kontakt, TOTP-Backup). Ein Teil der Nutzer hat Probleme mit der SMS-Zustellung (kein Netz, Telefonspeicher voll, falsche Nummer); ohne Backup-Option verlieren Sie diese Nutzer.

Häufige Fehler und wie Sie sie vermeiden

  1. Code im Klartext speichern: Wird die Datenbank kompromittiert, sind die Codes sofort nutzbar. Immer hashen.
  2. Math.random() in JS / rand() in PHP: vorhersagbar, in Sekunden geknackt. Kryptografischen Zufall verwenden.
  3. Kein Rate-Limit: Ein Bot verbraucht Ihr SMS-Guthaben, die Kosten explodieren. Immer drei Ebenen (Telefon, IP, Versuche).
  4. TTL über 10 Minuten: Das Angriffsfenster wächst exponentiell. Für gewöhnliche Zwecke 2–5 min, für Recovery max. 10 min.
  5. Code nicht an die Session binden: Ein Angreifer, der die SMS abfängt, kann den Code auf seinem Gerät verwenden. Session-Bindung: Speichern Sie einen Hash der Session-ID oder die IP-Adresse im OTP-Eintrag und prüfen Sie, dass die Verifizierung aus derselben Session kam.
  6. Code im Klartext loggen, auch in Fehlerprotokollen. Prüfen Sie die Protokollausgaben regelmäßig. Loggen Sie nur den Hash oder die letzten 2 Ziffern.
  7. Keine Drosselung pro Telefon: Ein Angreifer mit wechselnden IP-Adressen und demselben Zieltelefon kann das Opfer mit SMS bombardieren. Ein striktes Limit pro Nummer (max. 3/Stunde) löst das.
  8. Verbrauch nicht erfassen: Ohne Prüfung von consumed_at kann der Nutzer (oder ein Angreifer) denselben Code mehrfach verwenden.
  9. Nach dem Versuchslimit nicht abbrechen: Wer 100-mal falsch eingibt, trifft am Ende vielleicht. Nach 5 Fehlern ungültig machen.
  10. Keine Bereinigung abgelaufener Einträge: Die Tabelle wächst endlos, Abfragen werden langsam. Cron jede Stunde.

Fortgeschrittene Techniken für die Produktion

Risikobasierte Authentifizierung

Verlangen Sie nicht bei jeder Anmeldung ein OTP. Meldet sich der Nutzer von einem bekannten Gerät und einer bekannten IP-Adresse an (erkennbar über Cookie und IP-Fingerprint), überspringen Sie das OTP. Verlangen Sie es nur bei:

  • Anmeldung von einem neuen Standort
  • Anmeldung von einem neuen Gerät (kein „Angemeldet bleiben“-Cookie)
  • Aktionen mit hohem Wert (Zahlung, Kontolöschung, Passwortänderung, Änderung der 2FA)

Das Ergebnis: höhere Konversion, geringere SMS-Kosten, bessere UX.

Step-up-Authentifizierung

Statt eines binären Modells „OTP ja/nein“ implementieren Sie mehrere Stufen:

  • Stufe 0: kein OTP, nur Passwort. Für Aktionen mit geringem Wert (Konto ansehen, kleine Einkäufe).
  • Stufe 1: SMS-OTP. Für Aktionen mit mittlerem Wert (Warenkorb über etwa 200 €).
  • Stufe 2: SMS-OTP + TOTP oder Passkey. Für Aktionen mit hohem Wert (Zahlung, Passwortänderung, Datenexport).

Backup-Codes

Erzeugen Sie für den Nutzer 10 einmalige Backup-Codes, die er ausdrucken oder im Passwortmanager speichern kann. Hat er keinen Zugriff auf sein Telefon (Verlust, Diebstahl, Netzausfall), kann er sich mit einem der Backup-Codes anmelden. Die Implementierung ähnelt dem OTP, nur gelten die Codes unbegrenzt (bis zum Verbrauch) und werden einmalig erzeugt.

Erkennung von SIM-Swap

Beim SIM-Swap-Angriff überredet der Angreifer den Netzbetreiber, die Telefonnummer auf eine neue SIM-Karte zu übertragen. Erkennbar ist das über:

  • Änderung des Verhaltensmusters: Der Nutzer meldet sich plötzlich von einem anderen Standort und Gerät an und fordert ein OTP an
  • Sehr kurze Zeit zwischen Passwort-Reset und erster Anmeldung von einem neuen Gerät

Erkennen Sie ein SIM-Swap-Risiko, verlangen Sie eine höhere Verifizierungsstufe (TOTP, Biometrie) oder beschränken Sie vorübergehend Aktionen mit hohem Wert.

Compliance: DSGVO, PSD2, NIST

DSGVO

Die Telefonnummer ist ein personenbezogenes Datum im Sinne von Art. 4 DSGVO. Für die Verarbeitung:

  • Rechtsgrundlage: Art. 6 Abs. 1 lit. b (Vertragserfüllung), weil das OTP das Konto absichert, das Gegenstand des Vertrags mit dem Nutzer ist
  • Aufbewahrung: verbrauchte Einträge nach 30 Tagen löschen, ungenutzte nach 24 Stunden
  • Information: in der Datenschutzerklärung angeben, dass Telefonnummer und SMS-Inhalt verarbeitet werden

NIST 800-63B: Authenticator Assurance Levels

NIST (ein US-Standard, auf den auch in der EU oft verwiesen wird) stuft SMS-OTP als „restricted authenticator“ ein, weniger sicher als TOTP oder FIDO2 wegen des Risikos von SIM-Swap und abgefangenen SMS. Für AAL2 (mittlere Sicherheit) ist SMS-OTP allein akzeptabel. Für AAL3 (hohe Sicherheit, z. B. Gesundheitsdaten, Behördendienste) lässt NIST SMS-OTP nicht zu.

Die praktische Folge: Für Consumer-Banking und E-Commerce ist SMS-OTP Standard. Für kritische Anwendungen (IT-Sicherheit, Behördenportale) brauchen Sie TOTP oder Passkey.

PSD2 SCA (starke Kundenauthentifizierung)

Für Zahlungen in der EU verlangt die PSD2 eine starke Authentifizierung auf Basis von zwei von drei Faktoren: Wissen (Passwort), Besitz (Telefon), Inhärenz (Biometrie).

SMS-OTP allein erfüllt die PSD2 nicht, es ist nur ein Faktor (Besitz). Es muss um einen zweiten ergänzt werden: typischerweise Passwort oder PIN beim Auslösen der Zahlung plus OTP zur Bestätigung, also Wissen und Besitz kombiniert.

Tests und Monitoring

Für den Produktivbetrieb richten Sie ein:

Unit-Tests:

  • Die Codeerzeugung liefert eine sechsstellige numerische Zeichenkette
  • Der bcrypt-Hash fällt für denselben Code bei jedem Aufruf anders aus
  • Die Prüfung des richtigen Codes liefert ok: true
  • Die Prüfung eines falschen Codes erhöht attempts um 1
  • Nach 5 Fehlern wird unabhängig von der Richtigkeit too-many-attempts zurückgegeben

Integrationstests:

  • Rate-Limit pro Telefon: Die 4. Anfrage innerhalb einer Stunde liefert einen Fehler
  • Einmalverwendung: Nach dem Verbrauch kann derselbe Code nicht erneut verwendet werden
  • TTL: Nach 5 Minuten lässt sich der Code nicht mehr prüfen

Monitoring im Produktivbetrieb:

  • Fehlerrate der OTP-Prüfungen pro Telefon: Scheitern >50 % der Prüfungen von einem Telefon → Angriff oder Bot
  • OTP-Anfragerate pro IP: bei >100/Stunde → Sperre + Alarm
  • SMS-Zustellrate: Fällt sie unter 95 % → beim Gateway-Anbieter nachfragen
  • Durchschnittliche Prüfdauer: Steigt sie plötzlich → Datenbank-Performance prüfen

Kosten: Was es tatsächlich kostet

Für eine durchschnittliche Anwendung mit 10.000 monatlich aktiven Nutzern und SMS-OTP bei der Anmeldung, mit Empfängern in Deutschland, Österreich oder der übrigen EU zu 0,084 € pro SMS:

  • Anmeldehäufigkeit: durchschnittlich 4× pro Monat pro Nutzer = 40.000 OTP/Monat
  • Preis über TopSMS: 40.000 × 0,084 € = rund 3.358 €/Monat

Mit risikobasiertem Überspringen bei bekannten Geräten (50 % übersprungen) sinken die Kosten auf rund 1.679 €/Monat.

Mit Step-up-Architektur (OTP nur für sensible Aktionen, 20 % der Anmeldungen): 8.000 OTP pro Monat, rund 672 €/Monat. Empfänger in Tschechien und der Slowakei sind günstiger, 0,036–0,040 € pro SMS je nach Monatsvolumen (siehe Preise).

Die Kosten sind spürbar; deshalb ist eine durchdachte Architektur (risikobasiert plus Step-up) der Schlüssel zur Skalierbarkeit.

Fazit

OTP per SMS ist auch 2026 die Grundlage der Identitätsinfrastruktur für die meisten B2C- und B2B-Anwendungen. Die Implementierung ist keine Raketenwissenschaft, aber die Details entscheiden. Konzentrieren Sie sich auf:

  1. Hash + kryptografischer Zufall + bcrypt (Grundsicherheit)
  2. Rate-Limit in 3 Schichten (Telefon + IP + Versuche)
  3. Einmalverwendung + TTL nach Zweck
  4. Frontend mit autocomplete="one-time-code" (UX)
  5. Monitoring der Fehlerrate (Angriffserkennung)
  6. Risikobasiert + Step-up für Kosteneffizienz

Und wenn Ihre Anwendung Konten mit höherem Wert hat, belassen Sie SMS als Grundschicht und ergänzen Sie für sensible Aktionen TOTP oder Passkey (Kapitel „SMS vs. TOTP vs. Passkey“ oben).

Fragen zur Implementierung? Schreiben Sie uns an info@topsms.cz; bei konkreten Projekten beraten wir gern kostenlos zur Architektur.

Wie es weitergeht

  • Verify API: fertige Erzeugung und Prüfung des Codes, die Limits überwacht das Gateway
  • REST-API: Versand Ihres eigenen Codes über POST /api/sms/send, Webhooks mit Zustellberichten
  • SMS-API in 5 Minuten integrieren: Konto, API-Schlüssel, erste SMS
  • Preise: 0,084 € pro SMS nach Deutschland, Österreich und in die EU, 10 Test-SMS