Verificación de formularios con OTP por SMS

El envío de una contraseña de un solo uso (OTP) por SMS es un flujo común para la verificación del número de teléfono, la recuperación de la cuenta, la autenticación de aumento de privilegios y la confirmación de pagos. Cambiar entre un navegador y una app de mensajería por SMS para memorizar o copiar y pegar un código de varios dígitos genera fricción para el usuario, aumenta los porcentajes de abandono y deja a los usuarios vulnerables al phishing en dominios falsificados.

La combinación del lenguaje de marcado semántico <form>, <input autocomplete="one-time-code" inputmode="numeric">, el formato de SMS vinculado al origen (@domain #code) y la API de WebOTP (OTPCredential) crea un flujo de verificación por capas y mejorado de forma progresiva:

  1. Extracción y envío automáticos: Los navegadores que admiten la API de WebOTP verifican el origen con la línea @domain #code del SMS, completan el <input> y activan la verificación con un solo toque.
  2. Autocompletado de teclado vinculado al origen: Los navegadores y sistemas operativos que admiten autocomplete="one-time-code" (como Safari en iOS, iPadOS y macOS) analizan el mensaje @domain #code y sugieren el código en la barra de autocompletado del teclado solo cuando el dominio coincide.
  3. Entrada manual sin complicaciones: Los usuarios que ingresan un código desde otro dispositivo reciben un teclado numérico virtual a través de inputmode="numeric" sin los riesgos de usabilidad de <input type="number">.

Consideraciones de seguridad

La OTP por SMS verifica la posesión de un número de teléfono y proporciona una verificación de aumento de nivel conveniente, pero el transporte de SMS y los números de teléfono pueden estar sujetos a intercambio de SIM, reciclaje de números e interceptación. Siempre vincula la función Autocompletar del cliente y la API de WebOTP con el formato de SMS @domain #code vinculado al origen para que el navegador y el SO verifiquen que el dominio de destino coincida antes de sugerir o entregar el código. Para la autenticación principal resistente al phishing, prefiere las llaves de acceso en lugar de los códigos basados en SMS (consulta Orientación sobre las llaves de acceso).

Lenguaje de marcado de formularios semánticos

Une el control de verificación en un elemento semántico <form> con method="POST" y un <button type="submit"> explícito. Usa siempre un solo elemento <input> en lugar de dividir los dígitos en varios cuadros.

  • Usa type="text" con inputmode="numeric": Nunca uses type="number" para los códigos de OTP. Una contraseña de un solo uso es una cadena de identificador que puede contener ceros a la izquierda (como 012345), no una cantidad contable. El uso de type="number" agrega botones de incremento y decremento, puede quitar los ceros iniciales y permite cambios accidentales de valores con la rueda del mouse. También evita type="tel", que es una solución heredada para números de teléfono completos. Vincula type="text" con inputmode="numeric" para presentar un teclado virtual solo de dígitos en dispositivos móviles.
  • Agrega autocomplete="one-time-code": Indica a los navegadores y sistemas operativos que sugieran códigos de verificación por SMS entrantes en la barra de autocompletado cuando el <input> esté enfocado.
  • Coloca sugerencias de instrucciones sobre el <input>: Asocia un <label> visible con for y id, y coloca las instrucciones de formato (vinculadas a través de aria-describedby) visualmente sobre el <input> para que las ventanas emergentes de autocompletado y los teclados virtuales de dispositivos móviles nunca cubran las instrucciones.
  • No dividas los dígitos en varios elementos <input>: Los patrones de OTP de varias casillas (seis entradas maxlength="1" independientes) interrumpen la función nativa de autocompletar de autocomplete="one-time-code" en muchos navegadores, dificultan la acción de copiar y pegar, y complican la navegación con lectores de pantalla. Si se requiere un diseño visual segmentado, aplica diseño a un solo <input> con letter-spacing y una fuente monoespaciada.
<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;
}

Formato de mensaje SMS vinculado al origen

Para que la API de WebOTP y el autocompletado de autocomplete="one-time-code" vinculado al dominio de Safari verifiquen que el código pertenece a tu sitio web, tu servicio de SMS de backend debe dar formato a la última línea del mensaje de texto con el dominio de origen precedido por @ y la OTP precedida por #:

Your verification code is 123456.

@example.com #123456

Aplica las siguientes reglas de formato en el mensaje SMS saliente:

  • Incluye primero texto legible por humanos: Siempre comienza el SMS con una oración clara y legible por humanos que contenga el código para que los usuarios que lo escriban manualmente en otro dispositivo puedan leerlo con facilidad.
  • Coloca @<domain> #<otp> en la última línea: El carácter @ debe ser el primero de la línea final, sin líneas finales ni texto de firma después de él.
  • Usa solo el dominio del host: No incluyas un esquema de URL (https:// o http://), un puerto (:8080) ni una ruta de acceso (/verify) en el token @domain.
  • Separa los tokens con un solo espacio: Coloca exactamente un espacio entre @<domain> y #<otp>, y nunca incluyas espacios en blanco dentro del dominio ni entre # y el código.

Cómo integrar la API de WebOTP en tu flujo de verificación

La API de WebOTP (OTPCredential) requiere un contexto seguro (https:// en producción o http://localhost durante el desarrollo local). En lugar de pegar una secuencia de comandos DOMContentLoaded desconectada que consulta el DOM y llama a form.submit(), integra navigator.credentials.get() directamente en el ciclo de vida y el controlador de envío de tu IU de verificación:

  • Vincula el ciclo de vida de la solicitud al paso activo de la OTP: Comienza a escuchar la OTP cuando se renderice y active la entrada de verificación (por ejemplo, cuando se active el paso de la OTP después de que el usuario solicite un código por SMS y, de nuevo, cuando el usuario haga clic en el botón "Reenviar código").
  • Administra un AbortController en todas las rutas de salida: Pasa un AbortSignal (signal: ac.signal) a navigator.credentials.get(). Llama al ac.abort() en los siguientes casos:
    • El usuario envía el formulario de forma manual (o envía un código completado a través de autocomplete="one-time-code").
    • Se solicita un nuevo código por SMS ("Reenviar código"), lo que anula el controlador anterior antes de crear uno nuevo.
    • Se desactiva el componente, el diálogo o la ruta de verificación.
  • Activa el controlador de envío de la aplicación (form.requestSubmit()): Cuando navigator.credentials.get() se resuelva con otp, establece otpInput.value = otp.code y llama a form.requestSubmit() (o invoca directamente tu función de verificación). NO llames a form.submit(), ya que form.submit() omite los objetos de escucha de eventos submit (controladores event.preventDefault()/fetch()) y la validación de restricciones nativas.
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();

Verificación de iframe de origen cruzado

La recepción de una OTP por SMS dentro de un <iframe> de origen cruzado está restringida de forma predeterminada y se usa principalmente para flujos de confirmación de pagos incorporados (como la verificación de 3D Secure, en la que shop.example incorpora un desafío de la entidad emisora de bank.example). La compatibilidad con WebOTP dentro de un <iframe> de origen cruzado requiere tres parámetros de configuración coordinados:

  1. Vincula ambos orígenes en la última línea del SMS: Primero, incluye el dominio de incorporación de nivel superior (@shop.example), seguido de #<otp>, un espacio y el dominio del iframe incorporado (@bank.example):

    Your verification code is 123456.
    
    @shop.example #123456 @bank.example
    
  2. Envía el encabezado de respuesta HTTP Permissions-Policy: Delega otp-credentials al origen del iframe de origen cruzado en los encabezados HTTP del documento superior:

    Permissions-Policy: otp-credentials=(self "https://bank.example")
    
  3. Agrega allow="otp-credentials" al elemento <iframe>: Otorga de forma explícita la política de permisos en la etiqueta <iframe> de la página de incorporación y, además, incluye un atributo title descriptivo para los lectores de pantalla:

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

Dentro del documento <iframe> que entrega https://bank.example, ejecuta el mismo lenguaje de marcado <form> y el ciclo de vida de verificación de navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal }) que se mostraron anteriormente.

Estrategias de resguardo

Resguardo de la API de WebOTP

Si los navegadores de destino no admiten la API de WebOTP (OTPCredential), no cargues un polyfill de JavaScript de terceros, ya que la lectura de los mensajes SMS entrantes requiere una integración a nivel del sistema operativo. En su lugar, confía en la mejora progresiva de tres niveles integrada en el lenguaje de marcado <input>:

  • Experiencia de mejora progresiva: Protege la llamada a navigator.credentials.get({ otp: { transport: ['sms'] } }) detrás de if ('OTPCredential' in window). Cuando OTPCredential no está disponible:
    1. Los navegadores que admiten autocomplete="one-time-code" (como Safari en iOS 14 y versiones posteriores, iPadOS y macOS con retransmisión de SMS de iCloud) analizan el SMS de @domain #code y presentan el código en la barra de autocompletado del teclado del sistema cuando se enfoca el <input>.
    2. Todos los demás navegadores recurren a la entrada manual en el <input type="text" inputmode="numeric" pattern="\d{6}" maxlength="6" required> único, muestran un teclado numérico en los dispositivos móviles y se envían a través de <button type="submit">.

Resguardo de la política de permisos

Si el navegador no admite la delegación de otp-credentials a través de Permissions-Policy y <iframe allow="otp-credentials">, la verificación de <form> incorporada dentro del <iframe> de origen cruzado se degrada de forma correcta a sugerencias de teclado de autocomplete="one-time-code" y entrada numérica manual sin arrojar excepciones no controladas.

Compatibilidad con inputmode y AbortController

Tanto el atributo inputmode de HTML como AbortController (AbortSignal) son compatibles con los navegadores actualizados y no requieren código de polyfill ni de resguardo.