Validation de formulaire par OTP envoyé par SMS

L'envoi d'un code secret à usage unique (OTP) par SMS est une procédure courante pour la vérification du numéro de téléphone, la récupération de compte, l'authentification renforcée et la confirmation de paiement. Passer d'un navigateur à une application de messagerie SMS pour mémoriser ou copier-coller un code à plusieurs chiffres complique l'expérience utilisateur, augmente les taux d'abandon et expose les utilisateurs au hameçonnage sur des domaines usurpés.

En combinant le balisage sémantique <form>, <input autocomplete="one-time-code" inputmode="numeric">, la mise en forme des SMS liés à l'origine (@domain #code) et l'API WebOTP (OTPCredential), vous créez un flux de validation multicouche et progressivement amélioré :

  1. Extraction et envoi automatiques : les navigateurs compatibles avec l'API WebOTP vérifient l'origine par rapport à la ligne @domain #code du SMS, renseignent le <input> et déclenchent la validation d'un simple geste.
  2. Saisie automatique au clavier liée à l'origine : les navigateurs et les systèmes d'exploitation compatibles avec autocomplete="one-time-code" (comme Safari sur iOS, iPadOS et macOS) analysent le message @domain #code et suggèrent le code dans la barre de saisie automatique au clavier uniquement lorsque le domaine correspond.
  3. Saisie manuelle fluide : les utilisateurs qui saisissent un code depuis un autre appareil reçoivent un pavé numérique virtuel via inputmode="numeric" sans les problèmes d'ergonomie de <input type="number">.

Considérations de sécurité

Les OTP par SMS permettent de vérifier la possession d'un numéro de téléphone et d'effectuer une validation renforcée pratique. Toutefois, le transport des SMS et les numéros de téléphone peuvent être sujets au SIM swapping, au recyclage de numéros et à l'interception. Associez toujours la saisie automatique côté client et l'API WebOTP au format SMS @domain #code lié à l'origine afin que le navigateur et l'OS vérifient que le domaine cible correspond avant de suggérer ou de fournir le code. Pour l'authentification principale résistante au hameçonnage, privilégiez les clés d'accès aux codes par SMS (voir Présentation des clés d'accès).

Balisage sémantique des formulaires

Encapsulez la commande de validation dans un élément <form> sémantique avec method="POST" et un <button type="submit"> explicite. Utilisez toujours un seul élément <input> plutôt que de répartir les chiffres dans plusieurs cases.

  • Utilisez type="text" avec inputmode="numeric" : n'utilisez jamais type="number" pour les codes OTP. Un code secret à usage unique est une chaîne d'identification qui peut contenir des zéros au début (par exemple, 012345), et non une quantité dénombrable. L'utilisation de type="number" ajoute des boutons de sélection d'incrémentation/décrémentation, peut supprimer les zéros non significatifs et permet de modifier accidentellement la valeur de la molette de défilement. Évitez également type="tel", qui est une solution de contournement obsolète destinée aux numéros de téléphone complets. Associez type="text" à inputmode="numeric" pour afficher un clavier virtuel composé uniquement de chiffres sur les appareils mobiles.
  • Ajoutez autocomplete="one-time-code" : indique aux navigateurs et aux systèmes d'exploitation de suggérer les codes de validation par SMS entrants dans la barre de saisie automatique lorsque l'élément <input> est sélectionné.
  • Placez les instructions au-dessus de l'élément <input> : associez un élément <label> visible à l'aide de for et id, et placez toutes les instructions de format (associées via aria-describedby) visuellement au-dessus de l'élément <input> afin que les popovers de saisie automatique et les claviers virtuels mobiles ne masquent jamais les instructions.
  • Ne divisez pas les chiffres sur plusieurs éléments <input> : les modèles de codes à usage unique à plusieurs cases (six entrées maxlength="1" distinctes) interrompent la saisie automatique native autocomplete="one-time-code" dans de nombreux navigateurs, empêchent le copier-coller et compliquent la navigation avec un lecteur d'écran. Si une conception visuelle segmentée est requise, appliquez un style à un seul <input> avec letter-spacing et une police à chasse fixe.
<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;
}

Format des SMS liés à l'origine

Pour que l'API WebOTP et la saisie automatique autocomplete="one-time-code" liée au domaine de Safari puissent vérifier que le code appartient à votre site Web, votre service de SMS backend doit formater la dernière ligne du message avec le domaine d'origine précédé de @ et le mot de passe à usage unique précédé de # :

Your verification code is 123456.

@example.com #123456

Appliquez les règles de mise en forme suivantes au SMS sortant :

  • Incluez d'abord du texte lisible par l'humain : commencez toujours le SMS par une phrase claire et lisible par l'humain contenant le code, afin que les utilisateurs qui le saisissent manuellement sur un autre appareil puissent le lire facilement.
  • Placez @<domain> #<otp> sur la toute dernière ligne : le caractère @ doit être le premier caractère de la dernière ligne, sans aucune ligne ni texte de signature après.
  • N'utilisez que le domaine hôte : n'incluez pas de schéma d'URL (https:// ou http://), de port (:8080) ni de chemin d'accès (/verify) dans le jeton @domain.
  • Séparez les jetons par un seul espace : insérez exactement un espace entre @<domain> et #<otp>, et n'incluez jamais d'espace dans le domaine ni entre # et le code.

Intégrer l'API WebOTP à votre flux de validation

L'API WebOTP (OTPCredential) nécessite un contexte sécurisé (https:// en production ou http://localhost lors du développement local). Au lieu de coller un script DOMContentLoaded déconnecté qui interroge le DOM et appelle form.submit(), intégrez navigator.credentials.get() directement dans le cycle de vie et le gestionnaire d'envoi de votre UI de validation :

  • Associez le cycle de vie de la requête à l'étape de mot de passe à usage unique active : commencez à écouter le mot de passe à usage unique lorsque l'entrée de validation est affichée et active (par exemple, lorsque l'étape de mot de passe à usage unique est activée après que l'utilisateur a demandé un code par SMS, et chaque fois que l'utilisateur clique sur un bouton "Renvoyer le code").
  • Gérer un AbortController sur tous les chemins de sortie : transmettez un AbortSignal (signal: ac.signal) à navigator.credentials.get(). Appelez le ac.abort() dans les cas suivants :
    • L'utilisateur envoie manuellement le formulaire (ou envoie un code renseigné via autocomplete="one-time-code").
    • Un nouveau code SMS est demandé ("Renvoyer le code"), ce qui annule le contrôleur précédent avant d'en créer un autre.
    • Le composant, la boîte de dialogue ou la route de validation est démonté.
  • Déclenchez le gestionnaire d'envoi de l'application (form.requestSubmit()) : lorsque navigator.credentials.get() se résout avec otp, définissez otpInput.value = otp.code et appelez form.requestSubmit() (ou appelez directement votre fonction de validation). N'appelez PAS form.submit(), car form.submit() contourne les écouteurs d'événements submit (gestionnaires event.preventDefault()/fetch()) et ignore la validation des contraintes natives.
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();

Validation des iFrames interorigines

La réception d'un OTP par SMS dans un <iframe> interorigine est limitée par défaut et est principalement utilisée pour les flux de confirmation de paiement intégrés (comme la validation 3D Secure, où shop.example intègre un test d'émetteur de bank.example). La prise en charge de WebOTP dans un <iframe> interorigine nécessite trois configurations coordonnées :

  1. Associez les deux origines sur la dernière ligne de l'en-tête SMS : listez d'abord le domaine d'intégration de premier niveau (@shop.example), suivi de #<otp>, puis d'un espace et du domaine de l'iFrame intégré (@bank.example) :

    Your verification code is 123456.
    
    @shop.example #123456 @bank.example
    
  2. Envoyez l'en-tête de réponse HTTP Permissions-Policy : déléguez otp-credentials à l'origine de l'iframe interorigine dans les en-têtes HTTP du document parent :

    Permissions-Policy: otp-credentials=(self "https://bank.example")
    
  3. Ajoutez allow="otp-credentials" à l'élément <iframe> : accordez explicitement la règle d'autorisation sur la balise <iframe> de la page d'intégration et incluez un attribut title descriptif pour les lecteurs d'écran :

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

Dans le document <iframe> diffusé par https://bank.example, exécutez le même balisage <form> et le même cycle de vie de validation navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal }) que ceux indiqués ci-dessus.

Stratégies de remplacement

Solution de secours de l'API WebOTP

Si vos navigateurs cibles ne sont pas compatibles avec l'API WebOTP (OTPCredential), ne chargez pas de polyfill JavaScript tiers, car la lecture des SMS entrants nécessite une intégration au niveau du système d'exploitation. Utilisez plutôt l'amélioration progressive à trois niveaux intégrée au balisage <input> :

  • Expérience d'amélioration progressive : protégez l'appel navigator.credentials.get({ otp: { transport: ['sms'] } }) derrière if ('OTPCredential' in window). Lorsque OTPCredential n'est pas disponible :
    1. Les navigateurs compatibles avec autocomplete="one-time-code" (comme Safari sur iOS 14 ou version ultérieure, iPadOS et macOS avec le relais SMS iCloud) analysent le SMS @domain #code et affichent le code dans la barre de saisie automatique du clavier système lorsque l'élément <input> est sélectionné.
    2. Tous les autres navigateurs reviennent à la saisie manuelle dans l'élément <input type="text" inputmode="numeric" pattern="\d{6}" maxlength="6" required> unique, affichent un pavé numérique sur les appareils mobiles et envoient les données via l'élément <button type="submit">.

Remplacement de la règle d'autorisation

Si le navigateur n'est pas compatible avec la délégation de otp-credentials via Permissions-Policy et <iframe allow="otp-credentials">, la validation intégrée <form> dans l'<iframe> interorigine se dégrade de manière fluide en suggestions de clavier autocomplete="one-time-code" et en saisie numérique manuelle sans générer d'exceptions non gérées.

Compatibilité avec inputmode et AbortController

L'attribut HTML inputmode et AbortController (AbortSignal) sont tous deux compatibles avec les navigateurs récents et ne nécessitent pas de code polyfill ni de remplacement.