Verificação de formulário de OTP por SMS

O envio de uma senha de uso único (OTP, na sigla em inglês) por SMS é um fluxo comum para verificação de número de telefone, recuperação de conta, autenticação de aumento de privilégios e confirmação de pagamento. Alternar entre um navegador e um app de mensagens SMS para memorizar ou copiar e colar um código de vários dígitos gera atrito para o usuário, aumenta as taxas de desistência e deixa os usuários vulneráveis a phishing em domínios falsificados.

A combinação de marcação semântica <form>, <input autocomplete="one-time-code" inputmode="numeric">, formatação de SMS vinculada à origem (@domain #code) e a API WebOTP (OTPCredential) cria um fluxo de verificação em camadas e com aprimoramento progressivo:

  1. Extração e envio automáticos: os navegadores compatíveis com a API WebOTP verificam a origem em relação à linha @domain #code no SMS, preenchem o <input> e acionam a verificação com um único toque.
  2. Preenchimento automático de teclado vinculado à origem: navegadores e sistemas operacionais compatíveis com autocomplete="one-time-code" (como o Safari no iOS, iPadOS e macOS) analisam a mensagem @domain #code e sugerem o código na barra de preenchimento automático do teclado apenas quando o domínio corresponde.
  3. Entrada manual sem complicações: os usuários que inserem um código de outro dispositivo recebem um teclado virtual numérico via inputmode="numeric" sem os riscos de usabilidade de <input type="number">.

Considerações sobre segurança

A OTP por SMS verifica a posse de um número de telefone e oferece uma verificação de segurança conveniente, mas o transporte de SMS e os números de telefone podem estar sujeitos a troca de chip, reciclagem de números e interceptação. Sempre use o preenchimento automático do lado do cliente e a API WebOTP com o formato de SMS @domain #code vinculado à origem para que o navegador e a verificação do SO confirmem se o domínio de destino corresponde antes de sugerir ou enviar o código. Para a autenticação principal resistente a phishing, prefira chaves de acesso a códigos baseados em SMS (consulte Orientação sobre chaves de acesso).

Marcação de formulário semântico

Envolva o controle de verificação em um elemento semântico <form> com method="POST" e um <button type="submit"> explícito. Sempre use um único elemento <input> em vez de dividir os dígitos em várias caixas.

  • Use type="text" com inputmode="numeric": nunca use type="number" para códigos OTP. Uma senha de uso único é uma string de identificador que pode conter zeros à esquerda (como 012345), não uma quantidade contável. O uso de type="number" adiciona botões de incremento/decremento, pode remover zeros à esquerda e permite mudanças acidentais de valor com a roda de rolagem. Evite também type="tel", que é uma solução legada destinada a números de telefone completos. Combine type="text" com inputmode="numeric" para apresentar um teclado virtual somente com dígitos em dispositivos móveis.
  • Adicione autocomplete="one-time-code": instrui os navegadores e sistemas operacionais a sugerir códigos de verificação por SMS recebidos na barra de preenchimento automático quando o <input> estiver em foco.
  • Coloque dicas de instrução acima do <input>: associe um <label> visível usando for e id e coloque todas as instruções de formato (vinculadas por aria-describedby) visualmente acima do <input> para que os popovers de preenchimento automático e os teclados virtuais de dispositivos móveis nunca cubram as instruções.
  • Não divida dígitos em vários elementos <input>: padrões de OTP com várias caixas (seis entradas maxlength="1" separadas) interrompem o preenchimento automático nativo de autocomplete="one-time-code" em muitos navegadores, impedem a cópia e colagem e complicam a navegação do leitor de tela. Se um design visual segmentado for necessário, estilize um único <input> com letter-spacing e uma fonte monoespaçada.
<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 mensagem SMS vinculado à origem

Para que a API WebOTP e o preenchimento automático de autocomplete="one-time-code" vinculado ao domínio do Safari verifiquem se o código pertence ao seu site, o serviço de SMS de back-end precisa formatar a última linha da mensagem de texto com o domínio de origem prefixado por @ e a OTP prefixada por #:

Your verification code is 123456.

@example.com #123456

Aplique as seguintes regras de formatação à mensagem SMS enviada:

  • Inclua primeiro um texto legível por humanos: sempre comece o SMS com uma frase clara e legível por humanos que contenha o código para que os usuários que o digitarem manualmente em outro dispositivo possam ler com facilidade.
  • Coloque @<domain> #<otp> na última linha: o caractere @ precisa ser o primeiro da linha final, sem linhas extras ou texto de assinatura depois dele.
  • Use apenas o domínio hospedado: não inclua um esquema de URL (https:// ou http://), uma porta (:8080) ou um caminho (/verify) no token @domain.
  • Separe os tokens com um único espaço: coloque exatamente um espaço entre @<domain> e #<otp> e nunca inclua espaços em branco no domínio ou entre # e o código.

Como integrar a API WebOTP ao seu fluxo de verificação

A API WebOTP (OTPCredential) exige um contexto seguro (https:// na produção ou http://localhost durante o desenvolvimento local). Em vez de colar um script DOMContentLoaded desconectado que consulta o DOM e chama form.submit(), integre navigator.credentials.get() diretamente ao ciclo de vida e ao gerenciador de envio da sua interface de verificação:

  • Vincule o ciclo de vida da solicitação à etapa ativa da OTP: comece a detectar a OTP quando a entrada de verificação for renderizada e estiver ativa (por exemplo, quando a etapa da OTP for montada depois que o usuário solicitar um código SMS e sempre que o usuário clicar no botão "Reenviar código").
  • Gerenciar um AbortController em todos os caminhos de saída: transmita um AbortSignal (signal: ac.signal) para navigator.credentials.get(). Chame o ac.abort() quando:
    • O usuário envia o formulário manualmente (ou envia um código preenchido por autocomplete="one-time-code").
    • Um novo código SMS é solicitado ("Reenviar código"), cancelando o controlador anterior antes de criar um novo.
    • O componente, a caixa de diálogo ou a rota de verificação é desmontado.
  • Acionar o gerenciador de envio do aplicativo (form.requestSubmit()): quando navigator.credentials.get() for resolvido com otp, defina otpInput.value = otp.code e chame form.requestSubmit() (ou invoque a função de verificação diretamente). NÃO chame form.submit(), porque form.submit() ignora os listeners de eventos submit (manipuladores event.preventDefault() / fetch()) e a validação de restrição nativa.
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();

Verificação de iframe entre origens

O recebimento de uma OTP por SMS em um <iframe> entre origens é restrito por padrão e é usado principalmente para fluxos de confirmação de pagamento incorporados (como a verificação 3D Secure, em que shop.example incorpora um desafio do emissor de bank.example). Para oferecer suporte à WebOTP em um <iframe> entre origens, são necessárias três configurações coordenadas:

  1. Vincule as duas origens na última linha do SMS: liste primeiro o domínio de incorporação de nível superior (@shop.example), seguido por #<otp>, um espaço e o domínio do iframe incorporado (@bank.example):

    Your verification code is 123456.
    
    @shop.example #123456 @bank.example
    
  2. Envie o cabeçalho de resposta HTTP Permissions-Policy: delegue otp-credentials à origem do iframe entre origens nos cabeçalhos HTTP do documento pai:

    Permissions-Policy: otp-credentials=(self "https://bank.example")
    
  3. Adicione allow="otp-credentials" ao elemento <iframe>: conceda explicitamente a política de permissão na tag <iframe> na página de incorporação e inclua um atributo title descritivo para leitores de tela:

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

No documento <iframe> veiculado por https://bank.example, execute a mesma marcação <form> e o mesmo ciclo de vida de verificação navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal }) mostrados acima.

Estratégias de fallback

Substituição da API WebOTP

Se os navegadores de destino não forem compatíveis com a API WebOTP (OTPCredential), não carregue um polyfill de JavaScript de terceiros, porque a leitura de mensagens SMS recebidas requer integração no nível do sistema operacional. Em vez disso, use o aprimoramento progressivo de três níveis integrado à marcação <input>:

  • Experiência de aprimoramento progressivo: proteja a chamada navigator.credentials.get({ otp: { transport: ['sms'] } }) por trás de if ('OTPCredential' in window). Quando OTPCredential não está disponível:
    1. Os navegadores compatíveis com autocomplete="one-time-code" (como o Safari no iOS 14+, iPadOS e macOS com retransmissão de SMS do iCloud) analisam o SMS @domain #code e apresentam o código na barra de preenchimento automático do teclado do sistema quando o <input> está em foco.
    2. Todos os outros navegadores usam a entrada manual no único <input type="text" inputmode="numeric" pattern="\d{6}" maxlength="6" required>, exibindo um teclado numérico em dispositivos móveis e enviando pelo <button type="submit">.

Substituição da política de permissões

Se o navegador não for compatível com a delegação de otp-credentials usando Permissions-Policy e <iframe allow="otp-credentials">, a verificação incorporada <form> dentro do <iframe> entre origens vai ser degradada de forma otimizada para sugestões de teclado autocomplete="one-time-code" e entrada numérica manual sem gerar exceções não processadas.

Suporte a inputmode e AbortController

O atributo HTML inputmode e o AbortController (AbortSignal) são compatíveis com navegadores mais recentes e não exigem código polyfill ou substituto.