Formulierverificatie met eenmalig wachtwoord via sms

Een eenmalige toegangscode (OTP) via sms sturen is een veelgebruikte methode voor verificatie van telefoonnummer, accountherstel, stapsgewijze verificatie en betalingsbevestiging. Als gebruikers moeten schakelen tussen een browser en een berichten-app om een code van meerdere cijfers te onthouden of te kopiëren en te plakken, leidt dit tot frictie, hogere verlatingspercentages en kwetsbaarheid voor phishing op gespoofde domeinen.

Door semantische <form>-opmaak, <input autocomplete="one-time-code" inputmode="numeric">, aan de oorsprong gebonden sms-opmaak (@domain #code) en de WebOTP API (OTPCredential) te combineren, maak je een gelaagd, progressief verbeterd verificatieproces:

  1. Automatische extractie en indiening: Browsers die de WebOTP API ondersteunen, verifiëren de oorsprong aan de hand van de regel @domain #code in de sms, vullen de <input> in en activeren de verificatie met één tik.
  2. Automatisch invullen via toetsenbord gebonden aan oorsprong: Browsers en besturingssystemen die autocomplete="one-time-code" ondersteunen (zoals Safari op iOS, iPadOS en macOS) parseren het @domain #code-bericht en stellen de code alleen voor in de balk voor automatisch invullen via het toetsenbord als het domein overeenkomt.
  3. Probleemloze handmatige invoer: Gebruikers die een code van een ander apparaat invoeren, krijgen een numeriek virtueel toetsenblok via inputmode="numeric" zonder de gebruiksproblemen van <input type="number">.

Beveiligingsoverwegingen

Met een eenmalig wachtwoord via sms wordt het bezit van een telefoonnummer geverifieerd en wordt een handige stapsgewijze verificatie geboden, maar sms-overdracht en telefoonnummers kunnen vatbaar zijn voor simswaps, hergebruik van nummers en onderschepping. Koppel automatisch invullen aan de clientzijde en de WebOTP API altijd aan de @domain #code-indeling voor sms-berichten die aan de oorsprong zijn gebonden, zodat de browser en het OS verifiëren of het doeldomein overeenkomt voordat de code wordt voorgesteld of geleverd. Geef voor primaire phishingbestendige verificatie de voorkeur aan toegangssleutels boven sms-codes (zie Oriëntatie op toegangssleutels).

Semantische formulieropmaak

Plaats het verificatie-element in een semantisch <form>-element met method="POST" en een expliciete <button type="submit">. Gebruik altijd één <input>-element in plaats van cijfers over meerdere vakken te verdelen.

  • Gebruik type="text" met inputmode="numeric": Gebruik nooit type="number" voor OTP-codes. Een eenmalige toegangscode is een ID-tekenreeks die voorloopnullen kan bevatten (zoals 012345) en geen telbare hoeveelheid. Als je type="number" gebruikt, worden er spinnerknoppen voor verhogen/verlagen toegevoegd, kunnen voorloopnullen worden verwijderd en kunnen de waarden per ongeluk worden gewijzigd met het scrollwiel. Vermijd ook type="tel", een verouderde tijdelijke oplossing die is bedoeld voor volledige telefoonnummers. Koppel type="text" aan inputmode="numeric" om een virtueel toetsenbord met alleen cijfers te tonen op mobiele apparaten.
  • Add autocomplete="one-time-code": Geeft browsers en besturingssystemen de opdracht om inkomende sms-verificatiecodes voor te stellen in de balk voor automatisch invullen als de <input> is geselecteerd.
  • Plaats instructietips boven de <input>: Koppel een zichtbare <label> met for en id en plaats opmaakinstructies (gekoppeld via aria-describedby) visueel boven de <input>, zodat pop-overs voor automatisch invullen en mobiele virtuele toetsenborden de instructies nooit bedekken.
  • Splits cijfers niet over meerdere <input>-elementen: OTP-patronen met meerdere vakken (6 afzonderlijke maxlength="1"-invoeren) verstoren de systeemeigen functie voor automatisch invullen van autocomplete="one-time-code" in veel browsers, belemmeren kopiëren en plakken en bemoeilijken de navigatie met schermlezers. Als een gesegmenteerd visueel ontwerp vereist is, stijl je één <input> met letter-spacing en een monospace-lettertype.
<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;
}

Indeling voor sms-berichten die aan de oorsprong zijn gebonden

Als je wilt dat zowel de WebOTP API als de domeingebonden autocomplete="one-time-code"-functie voor automatisch invullen van Safari verifiëren dat de code bij je website hoort, moet je sms-service aan de backend de laatste regel van het tekstbericht opmaken met het oorspronkelijke domein voorafgegaan door @ en de OTP voorafgegaan door #:

Your verification code is 123456.

@example.com #123456

Handhaaf de volgende opmaakregels voor het uitgaande sms-bericht:

  • Zet eerst door mensen leesbare tekst: Begin de sms altijd met een duidelijke, door mensen leesbare zin die de code bevat, zodat gebruikers die de code handmatig op een ander apparaat typen, deze makkelijk kunnen lezen.
  • Plaats @<domain> #<otp> op de allerlaatste regel: Het teken @ moet het eerste teken van de laatste regel zijn, zonder volgende regels of handtekeningtekst erna.
  • Gebruik alleen het hostdomein: Neem geen URL-schema (https:// of http://), poort (:8080) of pad (/verify) op in de @domain-token.
  • Scheid tokens met één spatie: Plaats precies één spatie tussen @<domain> en #<otp> en gebruik nooit witruimte in het domein of tussen # en de code.

De WebOTP API integreren in je verificatieproces

De WebOTP API (OTPCredential) vereist een beveiligde context (https:// in productie of http://localhost tijdens lokale ontwikkeling). In plaats van een losstaand DOMContentLoaded-script te plakken dat de DOM opvraagt en form.submit() aanroept, integreert u navigator.credentials.get() rechtstreeks in de levenscyclus en de indieningshandler van uw verificatie-UI:

  • Koppel de levenscyclus van het verzoek aan de actieve OTP-stap: Begin te luisteren naar het OTP als de verificatie-invoer wordt weergegeven en actief is (bijvoorbeeld als de OTP-stap wordt geactiveerd nadat de gebruiker een sms-code heeft aangevraagd en opnieuw wanneer de gebruiker op de knop Code opnieuw sturen klikt).
  • Een AbortController beheren voor alle uitstappaden: Geef een AbortSignal (signal: ac.signal) door aan navigator.credentials.get(). Bel ac.abort() wanneer je wilt:
    • De gebruiker dient het formulier handmatig in (of dient een code in die is ingevuld via autocomplete="one-time-code").
    • Er wordt een nieuwe sms-code aangevraagd (Code opnieuw sturen). De vorige controller wordt afgebroken voordat er een nieuwe wordt gemaakt.
    • De verificatiecomponent, het dialoogvenster of de route wordt ontkoppeld.
  • De indieningshandler van de app activeren (form.requestSubmit()): Als navigator.credentials.get() wordt omgezet in otp, stel je otpInput.value = otp.code in en roep je form.requestSubmit() aan (of roep je de verificatiefunctie rechtstreeks aan). Roep NIET form.submit() aan, omdat form.submit() submit-event-listeners (event.preventDefault()-/fetch()-handlers) omzeilt en de validatie van systeemeigen beperkingen overslaat.
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();

Cross-origin iframe-verificatie

Een sms-eenmalig wachtwoord krijgen in een cross-origin <iframe> is standaard beperkt en wordt voornamelijk gebruikt voor ingesloten bevestigingsflows voor betalingen (zoals 3D Secure-verificatie waarbij shop.example een uitdaging van de kaartuitgever van bank.example insluit). Voor ondersteuning van WebOTP in een cross-origin <iframe> zijn 3 gecoördineerde configuraties vereist:

  1. Bind beide oorsprongen op de laatste regel van de sms: Vermeld eerst het ingesloten domein op het hoogste niveau (@shop.example), gevolgd door #<otp>, gevolgd door een spatie en het ingesloten iframe-domein (@bank.example):

    Your verification code is 123456.
    
    @shop.example #123456 @bank.example
    
  2. Stuur de HTTP-reactieheader Permissions-Policy: Delegeer otp-credentials naar de cross-origin iframe-oorsprong in de HTTP-headers van het bovenliggende document:

    Permissions-Policy: otp-credentials=(self "https://bank.example")
    
  3. Voeg allow="otp-credentials" toe aan het element <iframe>: Verleen het rechtenbeleid expliciet aan de tag <iframe> op de ingesloten pagina en voeg een beschrijvend kenmerk title toe voor schermlezers:

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

Voer in het <iframe>-document dat wordt geleverd door https://bank.example dezelfde <form>-opmaak en navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal })-verificatielevenscyclus uit als hierboven.

Reservestrategieën

WebOTP API-reserveoptie

Als je doelbrowsers de WebOTP API (OTPCredential) niet ondersteunen, laad je geen JavaScript-polyfill van derden, omdat voor het lezen van inkomende sms-berichten integratie op besturingssysteemniveau is vereist. Vertrouw in plaats daarvan op de progressive enhancement met 3 niveaus die is ingebouwd in de <input>-opmaak:

  • Progressive enhancement: Beveilig de aanroep van navigator.credentials.get({ otp: { transport: ['sms'] } }) achter if ('OTPCredential' in window). Als OTPCredential niet beschikbaar is:
    1. Browsers die autocomplete="one-time-code" ondersteunen (zoals Safari op iOS 14+, iPadOS en macOS met iCloud-sms-relay) parseren de @domain #code-sms en tonen de code in de balk voor automatisch invullen van het systeemtoetsenbord als de <input> is geselecteerd.
    2. Alle andere browsers vallen terug op handmatige invoer in het enkele <input type="text" inputmode="numeric" pattern="\d{6}" maxlength="6" required>, waarbij een numeriek toetsenblok wordt getoond op mobiele apparaten en wordt ingediend via de <button type="submit">.

Fallback voor rechtenbeleid

Als de browser de overdracht van otp-credentials via Permissions-Policy en <iframe allow="otp-credentials"> niet ondersteunt, wordt de ingesloten verificatie <form> in de cross-origin <iframe> op een goede manier gedegradeerd naar autocomplete="one-time-code"-toetsenbordsuggesties en handmatige numerieke invoer zonder onverwerkte uitzonderingen te genereren.

Ondersteuning voor inputmode en AbortController

Zowel het HTML-kenmerk inputmode als AbortController (AbortSignal) wordt ondersteund in moderne browsers en vereist geen polyfill- of fallback-code.