Подтверждение формы с помощью одноразового кода из SMS

Отправка одноразового кода доступа в SMS – распространенный способ подтверждения номера телефона, восстановления доступа к аккаунту, пошаговой аутентификации и подтверждения платежа. Переключение между браузером и приложением для обмена SMS-сообщениями, чтобы запомнить или скопировать многозначный код, создает неудобства для пользователей, увеличивает процент отказов и делает пользователей уязвимыми для фишинга в поддельных доменах.

Сочетание семантической разметки <form>, <input autocomplete="one-time-code" inputmode="numeric">, форматирования SMS с привязкой к источнику (@domain #code) и WebOTP API (OTPCredential) позволяет создать многоуровневый, процесс проверки с прогрессивным улучшением:

  1. Автоматическое извлечение и отправка. Браузеры, поддерживающие WebOTP API, проверяют источник по строке @domain #code в SMS, заполняют поле <input> и запускают проверку одним нажатием.
  2. Автозаполнение клавиатуры с привязкой к источнику. Браузеры и операционные системы, поддерживающие autocomplete="one-time-code" (например, Safari на iOS, iPadOS и macOS), анализируют сообщение @domain #code и предлагают код на панели автозаполнения клавиатуры, только если домен совпадает.
  3. Удобный ручной ввод. Пользователи, которые вводят код с другого устройства, получают виртуальную цифровую клавиатуру через inputmode="numeric" без проблем с удобством использования, характерных для <input type="number">.

Безопасность

Одноразовый код, отправленный по SMS, подтверждает владение номером телефона и обеспечивает удобную дополнительную проверку. Однако SMS и номера телефонов могут быть уязвимы к замене SIM-карты, повторному использованию номера и перехвату. Всегда используйте автозаполнение на стороне клиента и WebOTP API вместе с форматом SMS @domain #code, привязанным к источнику. Это позволит браузеру и ОС проверять, совпадает ли целевой домен, прежде чем предлагать или отправлять код. Для основной аутентификации, устойчивой к фишингу, рекомендуется использовать ключи доступа, а не коды из SMS (см. Руководство по ключам доступа).

Семантическая разметка форм

Оберните элемент управления подтверждением в семантический элемент <form> с method="POST" и явным <button type="submit">. Всегда используйте один элемент <input>, а не разделяйте цифры по нескольким полям.

  • Используйте type="text" с inputmode="numeric". Никогда не используйте type="number" для одноразовых кодов. Одноразовый код доступа – это строка идентификатора, которая может содержать нули в начале (например, 012345), а не исчисляемое количество. Если использовать type="number", добавляются кнопки увеличения и уменьшения значения, могут удаляться начальные нули и возможны случайные изменения значения при прокрутке колесиком мыши. Также не используйте type="tel", так как это устаревший способ, предназначенный для полных номеров телефонов. Используйте type="text" вместе с inputmode="numeric", чтобы на мобильных устройствах показывалась виртуальная клавиатура только с цифрами.
  • Добавьте autocomplete="one-time-code". Это позволит браузерам и операционным системам предлагать коды подтверждения из входящих SMS на панели автозаполнения, когда фокус находится на элементе <input>.
  • Размещайте подсказки над элементом <input>. Свяжите видимый элемент <label> с помощью for и id и разместите все инструкции по форматированию (связанные через aria-describedby) визуально над элементом <input>, чтобы всплывающие окна автозаполнения и мобильные виртуальные клавиатуры никогда не закрывали инструкции.
  • Не разделяйте цифры по нескольким элементам <input>. Если для каждой цифры одноразового кода используется отдельное поле (шесть элементов maxlength="1"), в некоторых браузерах не будет работать автозаполнение autocomplete="one-time-code". Кроме того, пользователям будет сложнее вставлять скопированный код и перемещаться по странице с помощью программы чтения с экрана. Если требуется сегментированный визуальный дизайн, оформите один элемент <input> с помощью letter-spacing и моноширинного шрифта.
<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;
}

Формат SMS-сообщений, привязанных к источнику

Чтобы WebOTP API и функция автозаполнения autocomplete="one-time-code" в Safari, привязанная к домену, могли подтвердить, что код относится к вашему сайту, ваш серверный SMS-сервис должен отформатировать последнюю строку текстового сообщения, указав домен источника с префиксом @ и одноразовый код с префиксом #:

Your verification code is 123456.

@example.com #123456

Примените следующие правила форматирования к исходящему SMS-сообщению:

  • Включите человекочитаемый текст в первую очередь: Всегда начинайте SMS с понятного человекочитаемого предложения, содержащего код, чтобы пользователи, которые вводят его вручную на другом устройстве, могли легко его прочитать.
  • Поместите символ @<domain> #<otp> в последнюю строку. Символ @ должен быть первым в последней строке. После него не должно быть других строк или текста подписи.
  • Используйте только домен хоста. Не включайте в токен @domain схему URL (https:// или http://), порт (:8080) или путь (/verify).
  • Разделяйте токены одним пробелом. Между @<domain> и #<otp> должен быть ровно один пробел. Не добавляйте пробелы в домен или между # и кодом.

Как интегрировать WebOTP API в процесс подтверждения

Для WebOTP API (OTPCredential) требуется безопасный контекст (https:// в рабочей среде или http://localhost при локальной разработке). Вместо того чтобы вставлять несвязанный скрипт DOMContentLoaded, который запрашивает DOM и вызывает form.submit(), интегрируйте navigator.credentials.get() непосредственно в жизненный цикл и обработчик отправки вашего интерфейса проверки:

  • Свяжите жизненный цикл запроса с активным шагом одноразового кода. Начните прослушивание одноразового кода, когда входные данные для подтверждения отображаются и активны (например, когда шаг одноразового кода монтируется после того, как пользователь запрашивает SMS-код, и снова, когда пользователь нажимает кнопку "Отправить код повторно").
  • Управление AbortController для всех путей выхода. Передайте AbortSignal (signal: ac.signal) в navigator.credentials.get(). Вы можете позвонить в ac.abort(), если:
    • Пользователь вручную отправляет форму (или отправляет код, заполненный через autocomplete="one-time-code").
    • Запрашивается новый SMS-код ("Отправить код повторно"), при этом предыдущий контроллер прерывается до создания нового.
    • Компонент, диалоговое окно или маршрут подтверждения отключается.
  • Активируйте обработчик отправки приложения (form.requestSubmit()). Когда navigator.credentials.get() разрешается с помощью otp, задайте otpInput.value = otp.code и вызовите form.requestSubmit() (или вызовите функцию подтверждения напрямую). НЕ вызывайте form.submit(), поскольку form.submit() обходит прослушиватели событий submit (обработчики event.preventDefault()/fetch()) и пропускает нативную проверку ограничений.
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();

Проверка iframe из разных источников

Получение одноразового кода из SMS в элементе <iframe> из разных источников по умолчанию ограничено и в основном используется для встроенных процессов подтверждения платежей (например, для проверки 3D Secure, когда shop.example встраивает запрос от эмитента из bank.example). Чтобы поддерживать WebOTP в элементе <iframe> из разных источников, необходимо выполнить три настройки:

  1. Свяжите оба источника в последней строке SMS. Сначала укажите домен верхнего уровня, в который встроен iframe (@shop.example), затем #<otp>, а после пробела – домен встроенного iframe (@bank.example):

    Your verification code is 123456.
    
    @shop.example #123456 @bank.example
    
  2. Отправьте заголовок HTTP-ответа Permissions-Policy. Делегируйте otp-credentials источнику междоменного iframe в заголовках HTTP родительского документа:

    Permissions-Policy: otp-credentials=(self "https://bank.example")
    
  3. Добавьте allow="otp-credentials" в элемент <iframe>. Явно укажите правила разрешений в теге <iframe> на странице, где размещен встроенный контент, и добавьте описательный атрибут title для программ чтения с экрана.

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

В документе <iframe>, который размещен на сервере https://bank.example, выполните те же действия по разметке <form> и проверке navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal }), что и выше.

Резервные стратегии

Резервный вариант WebOTP API

Если целевые браузеры не поддерживают WebOTP API (OTPCredential), не загружайте сторонний полифил JavaScript, поскольку для чтения входящих SMS-сообщений требуется интеграция на уровне операционной системы. Вместо этого используйте трехуровневое прогрессивное улучшение, встроенное в разметку <input>:

  • Прогрессивное улучшение. Защитите вызов navigator.credentials.get({ otp: { transport: ['sms'] } }) с помощью if ('OTPCredential' in window). Если OTPCredential недоступен:
    1. Браузеры, поддерживающие autocomplete="one-time-code" (например, Safari на iOS 14+, iPadOS и macOS с ретрансляцией SMS в iCloud), анализируют SMS @domain #code и показывают код на панели автозаполнения системной клавиатуры, когда фокус находится на <input>.
    2. Во всех остальных браузерах используется ручной ввод в одном поле <input type="text" inputmode="numeric" pattern="\d{6}" maxlength="6" required>, при этом на мобильных устройствах отображается цифровая клавиатура, а отправка выполняется через <button type="submit">.

Резервные правила разрешений

Если браузер не поддерживает делегирование otp-credentials через Permissions-Policy и <iframe allow="otp-credentials">, встроенная проверка <form> в междоменном <iframe> корректно переключается на подсказки клавиатуры autocomplete="one-time-code" и ручной ввод чисел без необработанных исключений.

Поддержка inputmode и AbortController

И атрибут HTML inputmode, и AbortController (AbortSignal) поддерживаются современными браузерами и не требуют полифила или резервного кода.