Отправка одноразового кода доступа в SMS – распространенный способ подтверждения номера телефона, восстановления доступа к аккаунту, пошаговой аутентификации и подтверждения платежа. Переключение между браузером и приложением для обмена SMS-сообщениями, чтобы запомнить или скопировать многозначный код, создает неудобства для пользователей, увеличивает процент отказов и делает пользователей уязвимыми для фишинга в поддельных доменах.
Сочетание семантической разметки <form>, <input autocomplete="one-time-code" inputmode="numeric">, форматирования SMS с привязкой к источнику (@domain #code) и WebOTP API (OTPCredential) позволяет создать многоуровневый, процесс проверки с прогрессивным улучшением:
- Автоматическое извлечение и отправка. Браузеры, поддерживающие WebOTP API, проверяют источник по строке
@domain #codeв SMS, заполняют поле<input>и запускают проверку одним нажатием. - Автозаполнение клавиатуры с привязкой к источнику. Браузеры и операционные системы, поддерживающие
autocomplete="one-time-code"(например, Safari на iOS, iPadOS и macOS), анализируют сообщение@domain #codeи предлагают код на панели автозаполнения клавиатуры, только если домен совпадает. - Удобный ручной ввод. Пользователи, которые вводят код с другого устройства, получают виртуальную цифровую клавиатуру через
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> из разных источников, необходимо выполнить три настройки:
Свяжите оба источника в последней строке SMS. Сначала укажите домен верхнего уровня, в который встроен iframe (
@shop.example), затем#<otp>, а после пробела – домен встроенного iframe (@bank.example):Your verification code is 123456. @shop.example #123456 @bank.exampleОтправьте заголовок HTTP-ответа
Permissions-Policy. Делегируйтеotp-credentialsисточнику междоменного iframe в заголовках HTTP родительского документа:Permissions-Policy: otp-credentials=(self "https://bank.example")Добавьте
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недоступен:- Браузеры, поддерживающие
autocomplete="one-time-code"(например, Safari на iOS 14+, iPadOS и macOS с ретрансляцией SMS в iCloud), анализируют SMS@domain #codeи показывают код на панели автозаполнения системной клавиатуры, когда фокус находится на<input>. - Во всех остальных браузерах используется ручной ввод в одном поле
<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) поддерживаются современными браузерами и не требуют полифила или резервного кода.