Formularbestätigung per SMS-OTP

Die Zustellung eines einmaligen Sicherheitscodes (OTP) per SMS ist ein gängiges Verfahren für die Bestätigung der Telefonnummer, die Kontowiederherstellung, die Step-up-Authentifizierung und die Zahlungsbestätigung. Der Wechsel zwischen einem Browser und einer SMS-Messaging-App, um sich einen mehrstelligen Code zu merken oder ihn zu kopieren und einzufügen, ist für Nutzer umständlich, erhöht die Abbruchrate und macht Nutzer anfällig für Phishing auf gefälschten Domains.

Durch die Kombination von semantischem <form>-Markup, <input autocomplete="one-time-code" inputmode="numeric">, ursprungsgebundener SMS-Formatierung (@domain #code) und der WebOTP API (OTPCredential) entsteht ein mehrstufiger, progressiv verbesserter Bestätigungsablauf:

  1. Automatische Extraktion und Übermittlung: Browser, die die WebOTP API unterstützen, vergleichen den Ursprung mit der Zeile @domain #code in der SMS, füllen das Feld <input> aus und lösen die Bestätigung mit einem einzigen Tippen aus.
  2. Ursprungsgebundenes automatisches Ausfüllen über die Tastatur: Browser und Betriebssysteme, die autocomplete="one-time-code" unterstützen (z. B. Safari unter iOS, iPadOS und macOS), parsen die @domain #code-Nachricht und schlagen den Code in der Leiste für das automatische Ausfüllen über die Tastatur nur dann vor, wenn die Domain übereinstimmt.
  3. Reibungslose manuelle Eingabe: Nutzer, die einen Code von einem anderen Gerät eingeben, erhalten über inputmode="numeric" ein numerisches virtuelles Tastenfeld, ohne die mit <input type="number"> verbundenen Probleme bei der Nutzerfreundlichkeit.

Sicherheitsaspekte

Mit einem Einmalpasswort per SMS wird der Besitz einer Telefonnummer bestätigt und eine bequeme zusätzliche Bestätigung ermöglicht. Die SMS-Übertragung und Telefonnummern können jedoch von SIM-Swapping, Nummernrecycling und Abfangen betroffen sein. Kombiniere das clientseitige automatische Ausfüllen und die WebOTP API immer mit dem ursprungsgebundenen SMS-Format @domain #code, damit der Browser und die Betriebssystemüberprüfung prüfen, ob die Zieldomain übereinstimmt, bevor der Code vorgeschlagen oder bereitgestellt wird. Für die primäre phishing-resistente Authentifizierung sollten Sie Passkeys gegenüber SMS-basierten Codes bevorzugen (siehe Passkeys-Orientierung).

Semantisches Formular-Markup

Verpacken Sie das Bestätigungselement in ein semantisches <form>-Element mit method="POST" und einem expliziten <button type="submit">. Verwenden Sie immer ein einzelnes <input>-Element, anstatt die Ziffern auf mehrere Felder aufzuteilen.

  • type="text" mit inputmode="numeric" verwenden: Verwenden Sie type="number" niemals für OTP-Codes. Ein einmaliger Sicherheitscode ist ein String, der führende Nullen enthalten kann (z. B. 012345), und keine zählbare Menge. Wenn Sie type="number" verwenden, werden Schaltflächen zum Erhöhen/Verringern hinzugefügt, führende Nullen können entfernt werden und es kann zu versehentlichen Wertänderungen durch das Scrollrad kommen. Vermeiden Sie auch type="tel", eine veraltete Problemumgehung für vollständige Telefonnummern. Kombinieren Sie type="text" mit inputmode="numeric", um auf Mobilgeräten eine Bildschirmtastatur nur mit Ziffern anzuzeigen.
  • autocomplete="one-time-code" hinzufügen: Browser und Betriebssysteme werden angewiesen, eingehende SMS-Bestätigungscodes in der Autofill-Leiste vorzuschlagen, wenn das <input> fokussiert ist.
  • Anleitungshinweise über dem <input> platzieren: Verknüpfe ein sichtbares <label> mit for und id und platziere alle Formatanweisungen (verknüpft über aria-describedby) visuell über dem <input>, damit die Anweisungen nicht von Pop-overs für automatisches Ausfüllen und Bildschirmtastaturen auf Mobilgeräten verdeckt werden.
  • Ziffern nicht auf mehrere <input>-Elemente aufteilen: OTP-Muster mit mehreren Feldern (sechs separate maxlength="1"-Eingaben) beeinträchtigen in vielen Browsern das native automatische Ausfüllen von autocomplete="one-time-code", erschweren das Kopieren und Einfügen und verkomplizieren die Navigation mit Screenreadern. Wenn ein segmentiertes visuelles Design erforderlich ist, formatieren Sie ein einzelnes <input> mit letter-spacing und einer Festbreitenschrift.
<form id="otp-form" action="/verify-otp" method="POST">
  <div class="form-group">
    <label for="otp-input">Verification code</label>
    <!-- Place the hint above the input so autofill popovers and mobile keyboards do not obscure it -->
    <p id="otp-format-hint" class="field-hint">
      Enter the 6-digit code sent to your phone.
    </p>
    <!-- MANDATORY: Use a single input with type="text", inputmode="numeric", and autocomplete="one-time-code" -->
    <input
      type="text"
      id="otp-input"
      name="otp"
      inputmode="numeric"
      autocomplete="one-time-code"
      pattern="\d{6}"
      maxlength="6"
      aria-describedby="otp-format-hint"
      required>
  </div>
  <button type="submit">Verify code</button>
</form>
/* Optional: Style a single input to visually space digits without splitting the DOM element */
#otp-input {
  font-family: ui-monospace, monospace;
  font-size: 1.25rem; /* Example value: must be at least 1rem (16px) to prevent mobile browser auto-zoom */
  letter-spacing: 0.35em;
  min-height: 48px;
  padding: 0.5rem 0.75rem;
  box-sizing: border-box;
}

Ursprungsgebundenes SMS-Format

Damit sowohl die WebOTP API als auch die domainabhängige autocomplete="one-time-code"-Autofill-Funktion von Safari überprüfen können, ob der Code zu Ihrer Website gehört, muss Ihr Backend-SMS-Dienst die letzte Zeile der Nachricht so formatieren, dass die Ursprungsdomain mit dem Präfix @ und das OTP mit dem Präfix # versehen ist:

Your verification code is 123456.

@example.com #123456

Erzwingen Sie die folgenden Formatierungsregeln für die ausgehende SMS:

  • Menschenlesbaren Text zuerst einfügen: Beginnen Sie die SMS immer mit einem klaren, menschenlesbaren Satz, der den Code enthält, damit Nutzer, die ihn manuell auf einem anderen Gerät eingeben, ihn leicht lesen können.
  • @<domain> #<otp> in die allerletzte Zeile einfügen: Das Zeichen @ muss das erste Zeichen der letzten Zeile sein. Danach dürfen keine weiteren Zeilen oder Signaturtext folgen.
  • Nur die Hostdomain verwenden: Geben Sie im Token @domain kein URL-Schema (https:// oder http://), keinen Port (:8080) und keinen Pfad (/verify) an.
  • Tokens durch ein einzelnes Leerzeichen trennen: Fügen Sie genau ein Leerzeichen zwischen @<domain> und #<otp> ein. Fügen Sie niemals Leerzeichen innerhalb der Domain oder zwischen # und dem Code ein.

WebOTP API in den Bestätigungsablauf einbinden

Die WebOTP API (OTPCredential) erfordert einen sicheren Kontext (https:// in der Produktion oder http://localhost während der lokalen Entwicklung). Anstatt ein unverbundenes DOMContentLoaded-Skript einzufügen, das das DOM abfragt und form.submit() aufruft, binden Sie navigator.credentials.get() direkt in den Lebenszyklus und den Übermittlungs-Handler Ihrer Bestätigungs-UI ein:

  • Anfragelebenszyklus an den aktiven OTP-Schritt binden: Beginnen Sie mit dem Abhören des OTP, wenn die Bestätigungseingabe gerendert und aktiv ist (z. B. wenn der OTP-Schritt bereitgestellt wird, nachdem der Nutzer einen SMS-Code angefordert hat, und jedes Mal, wenn der Nutzer auf die Schaltfläche „Code noch einmal senden“ klickt).
  • AbortController für alle Exit-Pfade verwalten: Übergeben Sie ein AbortSignal (signal: ac.signal) an navigator.credentials.get(). Rufe ac.abort() an, wenn:
    • Der Nutzer sendet das Formular manuell (oder sendet einen über autocomplete="one-time-code" ausgefüllten Code).
    • Ein neuer SMS-Code wird angefordert („Code noch einmal senden“). Der vorherige Controller wird abgebrochen, bevor ein neuer erstellt wird.
    • Die Bestätigungskomponente, das Dialogfeld oder die Route wird entfernt.
  • Den Handler für die Übermittlung der Anwendung (form.requestSubmit()) auslösen: Wenn navigator.credentials.get() mit otp aufgelöst wird, legen Sie otpInput.value = otp.code fest und rufen Sie form.requestSubmit() auf (oder rufen Sie Ihre Bestätigungsfunktion direkt auf). Rufen Sie form.submit() NICHT auf, da form.submit() die submit-Event-Listener (event.preventDefault()- / fetch()-Handler) umgeht und die native Einschränkungsvalidierung überspringt.
const otpForm = document.getElementById('otp-form');
const otpInput = document.getElementById('otp-input');

let otpAbortController = null;

function stopSmsOtpListener() {
  if (otpAbortController) {
    otpAbortController.abort();
    otpAbortController = null;
  }
}

async function startSmsOtpListener() {
  // Feature-detect WebOTP support
  if (!otpInput || !('OTPCredential' in window)) {
    return;
  }

  // Cancel any previous pending WebOTP request (e.g., when re-sending an SMS code)
  stopSmsOtpListener();
  const ac = new AbortController();
  otpAbortController = ac;

  try {
    const otp = await navigator.credentials.get({
      otp: { transport: ['sms'] },
      signal: ac.signal,
    });

    if (!otp) return;
    otpInput.value = otp.code;

    // Trigger standard form submission so submit event listeners and validation run
    if (otpForm) {
      otpForm.requestSubmit();
    }
  } catch (err) {
    // Expected when aborted by manual submission, resend, unmount, or user dismissal
    if (err && err.name === 'AbortError') {
      return;
    }
    console.error('WebOTP verification error:', err);
  }
}

otpForm.addEventListener('submit', async (event) => {
  // MANDATORY: Abort any in-flight WebOTP request when the form is submitted
  stopSmsOtpListener();

  // Application-specific submission logic (e.g., AJAX fetch or native POST)
  event.preventDefault();
  const formData = new FormData(otpForm);
  // await fetch(otpForm.action, { method: 'POST', body: formData });
});

// Start listening when the OTP verification step becomes active
startSmsOtpListener();

Überprüfung von ursprungsübergreifenden iFrames

Der Empfang eines SMS-OTP innerhalb eines ursprungsübergreifenden <iframe> ist standardmäßig eingeschränkt und wird hauptsächlich für eingebettete Zahlungsbestätigungsabläufe verwendet (z. B. 3D Secure-Verifizierung, bei der shop.example eine Aussteller-Challenge von bank.example einbettet). Die Unterstützung von WebOTP innerhalb eines ursprungsübergreifenden <iframe> erfordert drei koordinierte Konfigurationen:

  1. Beide Ursprünge in der letzten Zeile der SMS binden: Geben Sie zuerst die übergeordnete Einbettungsdomain (@shop.example) an, gefolgt von #<otp>, einem Leerzeichen und der eingebetteten iFrame-Domain (@bank.example):

    Your verification code is 123456.
    
    @shop.example #123456 @bank.example
    
  2. Permissions-Policy-HTTP-Antwortheader senden: Delegieren Sie otp-credentials in den HTTP-Headern des übergeordneten Dokuments an den ursprungsübergreifenden iFrame-Ursprung:

    Permissions-Policy: otp-credentials=(self "https://bank.example")
    
  3. allow="otp-credentials" zum <iframe>-Element hinzufügen: Erteilen Sie die Berechtigungsrichtlinie explizit im <iframe>-Tag auf der einbettenden Seite und fügen Sie ein beschreibendes title-Attribut für Screenreader hinzu:

<iframe
  src="https://bank.example/verify"
  allow="otp-credentials"
  title="Payment verification">
</iframe>

Führen Sie im Dokument <iframe>, das von https://bank.example bereitgestellt wird, dasselbe <form>-Markup und denselben navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal })-Bestätigungslebenszyklus wie oben beschrieben aus.

Fallback-Strategien

WebOTP API-Fallback

Wenn die von Ihnen verwendeten Browser die WebOTP API (OTPCredential) nicht unterstützen, laden Sie kein JavaScript-Polyfill eines Drittanbieters, da das Lesen eingehender SMS-Nachrichten eine Integration auf Betriebssystemebene erfordert. Verlassen Sie sich stattdessen auf die dreistufige progressive Verbesserung, die in das <input>-Markup integriert ist:

  • Progressive Verbesserung: Schützen Sie den navigator.credentials.get({ otp: { transport: ['sms'] } })-Aufruf hinter if ('OTPCredential' in window). Wenn OTPCredential nicht verfügbar ist:
    1. Browser, die autocomplete="one-time-code" unterstützen (z. B. Safari unter iOS 14 und höher, iPadOS und macOS mit iCloud-SMS-Relay), parsen die @domain #code-SMS und zeigen den Code in der Autofill-Leiste der Systemtastatur an, wenn das <input> fokussiert ist.
    2. Bei allen anderen Browsern wird auf die manuelle Eingabe im einzelnen <input type="text" inputmode="numeric" pattern="\d{6}" maxlength="6" required> zurückgegriffen. Dabei wird auf Mobilgeräten ein numerisches Tastenfeld angezeigt und die Eingabe erfolgt über das <button type="submit">.

Fallback für Berechtigungsrichtlinie

Wenn der Browser die Delegierung von otp-credentials über Permissions-Policy und <iframe allow="otp-credentials"> nicht unterstützt, wird die eingebettete Bestätigung <form> innerhalb des ursprungsübergreifenden <iframe> ordnungsgemäß auf autocomplete="one-time-code"-Tastaturvorschläge und manuelle numerische Eingabe herabgestuft, ohne dass unbehandelte Ausnahmen ausgelöst werden.

Unterstützung für „inputmode“ und „AbortController“

Sowohl das HTML-Attribut inputmode als auch AbortController (AbortSignal) werden von modernen Browsern unterstützt und erfordern keinen Polyfill- oder Fallback-Code.