Как устроена СМС‑верификация изнутри: генерация OTP‑кода, хранение и проверка, отправка через шлюз, защита от SMS‑бомберов. С нашим SDK для формы, примерами на PHP, Node.js и curl и ссылками на документацию шлюзов.
Сначала о том, где рождается код и кто его проверяет, — от этого зависит, для каких форм подходит решение.
Эта страница — для разработчиков, которые собирают подтверждение номера сами. Если нужно объяснение без кода, начните со страницы как работает СМС‑верификация, а живую форму можно попробовать на демо для Tilda и Битрикс.
Любая схема OTP состоит из четырёх частей:
Главный вопрос архитектуры — кто знает код. От ответа зависит, для каких задач подходит решение.
Так устроен виджет sms-otp.js: код генерируется в браузере через Math.random(), браузер сам сравнивает введённые цифры, а сервер verify.php только получает {phone, verificationCode} и передаёт текст шлюзу. Опционально пара «телефон:код» кодируется XOR + Base64 общим ключом.
Сервер генерирует код криптостойким генератором (random_int в PHP, crypto.randomInt в Node.js), хранит только хеш со сроком жизни и счётчиком попыток, проверяет сам и отдаёт браузеру лишь «да/нет».
Наш виджет sms-otp.js v1.2.0 перехватывает отправку любой HTML‑формы, показывает экран ввода кода и пропускает исходную отправку только после верного кода. Без зависимостей, работает с Tilda, 1С‑Битрикс и обычными формами.
<form id="lead-form"> <input name="Name"> <input name="Phone" type="tel"> <button type="submit">Отправить</button> </form> <script src="/js/sdk/sms-otp-v1.2.0.js"></script> <script> SmsOTP.init({ formSelector: '#lead-form', phoneFieldName: 'Phone', mode: 'production', // 'demo' — код показывается на экране, СМС не уходит endpoint: '/sms-verify.php', secretKey: '…32+ символа, тот же, что в конфиге бэкенда…', digits: 4, countries: ['7', '375', '994'], resendDelay: 60 }); </script>
Режим mode задавайте явно: в README по умолчанию указан production, а в коде v1.2.0 — demo. Основные параметры:
| Параметр | По умолчанию | Назначение |
|---|---|---|
formSelector | 'form' | CSS‑селектор перехватываемых форм |
phoneFieldName | 'Phone' | атрибут name поля телефона |
endpoint | — | URL бэкенда, который отправляет СМС |
digits | 4 | длина кода: 4 или 6 |
countries | ['7','375','994'] | разрешённые коды стран; номер проверяется по длине |
resendDelay | 60 | секунд до кнопки «Отправить повторно» |
Бэкенды для разных провайдеров (providers/prosmo-sms/verify.php для SMS Просто, providers/aerosms/verify.php для SMS Aero) принимают и возвращают одно и то же. Поэтому смена шлюза — это замена одной строки SMS_ENDPOINT на фронтенде, без правки формы.
# Отправить код POST /sms-verify.php Content-Type: application/json { "phone": "79991234567", "verificationCode": "4827" } # prosmo-sms с заданным secret_key принимает и { "encodedPayload": "<base64>" } 200 { "success": true, "sms_id": "…" } 400 { "success": false, "error": "phone and verificationCode are required" } 502 { "success": false, "error": "SMS provider unreachable" } # Проверить ключ и баланс шлюза GET /sms-verify.php?action=test 200 { "success": true, "message": "API key is valid", "balance": … }
curl "https://your-site.ru/sms-verify.php?action=test"
curl -X POST "https://your-site.ru/sms-verify.php" \
-H "Content-Type: application/json" \
-d '{"phone":"79991234567","verificationCode":"4827"}'
Поле sms_id из ответа стоит сохранить: по нему потом сопоставляется статус доставки из webhook. Как это выглядит в живой форме, можно посмотреть на демо‑странице для Tilda и Битрикс.
Для регистрации и входа в личный кабинет браузеру нельзя знать код. Ниже — упрощённые эндпоинты по серверной схеме: два действия, send и check. Ключи шлюза берутся из переменных окружения и не попадают во фронтенд.
Отправка идёт методом push_msg SMS Просто, как в нашем providers/prosmo-sms/verify.php. Код хранится в сессии в виде хеша, со сроком жизни и счётчиком попыток.
<?php session_start(); header('Content-Type: application/json; charset=utf-8'); $in = json_decode(file_get_contents('php://input'), true) ?: []; $phone = preg_replace('/\D/', '', $in['phone'] ?? ''); function reply(int $status, array $body) { http_response_code($status); exit(json_encode($body)); } if (($in['action'] ?? '') === 'send') { if (!preg_match('/^7\d{10}$/', $phone)) reply(400, ['success' => false]); if (time() - ($_SESSION['otp']['sent_at'] ?? 0) < 60) reply(429, ['success' => false]); $code = (string) random_int(1000, 9999); // криптостойкий ГСЧ $_SESSION['otp'] = [ 'phone' => $phone, 'hash' => password_hash($code, PASSWORD_DEFAULT), 'exp' => time() + 300, // 5 минут 'tries' => 0, 'sent_at' => time(), ]; reply(200, ['success' => send_sms($phone, "Ваш код: $code")]); } if (($in['action'] ?? '') === 'check') { $otp = $_SESSION['otp'] ?? null; if (!$otp || $otp['phone'] !== $phone || time() > $otp['exp']) reply(410, ['success' => false]); if (++$_SESSION['otp']['tries'] > 5) { unset($_SESSION['otp']); reply(429, ['success' => false]); } if (!password_verify((string) ($in['code'] ?? ''), $otp['hash'])) reply(400, ['success' => false]); unset($_SESSION['otp']); $_SESSION['verified_phone'] = $phone; // дальше форма/регистрация читает только это reply(200, ['success' => true]); } function send_sms(string $phone, string $text): bool { $ch = curl_init('http://api.sms-prosto.ru/'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => [ 'method' => 'push_msg', 'api_v' => '2.0', 'format' => 'json', 'key' => getenv('SMS_API_KEY'), 'phone' => $phone, 'text' => $text, 'sender_name' => getenv('SMS_SENDER'), ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, ]); $r = json_decode((string) curl_exec($ch), true); return (int) ($r['response']['msg']['err_code'] ?? -1) === 0; }
Сессионный лимит в примере — минимальная защита: бот без cookie получает новую сессию на каждый запрос. В продакшене лимиты по номеру и IP держат в Redis или в базе (подробнее — в разделе про защиту ниже).
Тот же сценарий на Node.js 18+ (встроенный fetch). Отправка — через шлюз SMS Aero v2 с HTTP Basic Auth (email + API‑ключ), как в providers/aerosms/verify.php.
import express from 'express'; import crypto from 'node:crypto'; const app = express(); app.use(express.json()); const store = new Map(); // phone → { hash, exp, tries, sentAt }; в проде — Redis с TTL const sha = (s) => crypto.createHash('sha256').update(s).digest(); const clean = (p) => String(p || '').replace(/\D/g, ''); app.post('/otp/send', async (req, res) => { const phone = clean(req.body.phone); if (!/^7\d{10}$/.test(phone)) return res.status(400).json({ success: false }); const prev = store.get(phone); if (prev && Date.now() - prev.sentAt < 60_000) return res.status(429).json({ success: false }); const code = String(crypto.randomInt(1000, 10000)); store.set(phone, { hash: sha(phone + code), exp: Date.now() + 300_000, tries: 0, sentAt: Date.now() }); const qs = new URLSearchParams({ number: phone, text: `Ваш код: ${code}`, sign: process.env.SMS_SIGN }); const auth = Buffer.from(`${process.env.SMSAERO_EMAIL}:${process.env.SMSAERO_KEY}`).toString('base64'); const r = await fetch('https://gate.smsaero.ru/v2/sms/send?' + qs, { headers: { Authorization: 'Basic ' + auth } }); const data = await r.json(); res.json({ success: Boolean(data.success) }); }); app.post('/otp/check', (req, res) => { const phone = clean(req.body.phone); const otp = store.get(phone); if (!otp || Date.now() > otp.exp) return res.status(410).json({ success: false }); if (++otp.tries > 5) { store.delete(phone); return res.status(429).json({ success: false }); } const ok = crypto.timingSafeEqual(otp.hash, sha(phone + String(req.body.code || ''))); if (ok) store.delete(phone); res.status(ok ? 200 : 400).json({ success: ok }); }); app.listen(3000);
Каждая СМС стоит денег, поэтому открытый эндпоинт отправки — первая цель для ботов. Ниже отдельно: что реально есть в нашем коде и что стоит добавить.
resendDelay секунд (по умолчанию 60). Это ограничение на стороне клиента: оно защищает от случайных двойных кликов, но не от скрипта, который шлёт запросы напрямую.7, 375, 994) и проверяет длину номера — мусорный ввод не доходит до шлюза.verify.php отдаёт Access-Control-Allow-Origin только для домена сайта из конфига. Чужой сайт не сможет вызвать эндпоинт из браузера, но curl этим заголовком не остановить.providers/aerosms/mobile-auth/backend.php): запрос с чужого Origin получает 403, а превышение лимита запросов с одного IP (по умолчанию 30 за 60 секунд) — 429. Это файловый лимит в режиме best effort, и в verify.php для СМС его пока нет.Ниже — рекомендации, а не описание текущего кода. Для публичной формы мы считаем их обязательным минимумом:
GET ?action=test возвращает остаток — резкое падение за час стоит превратить в алерт.Защита формы от спама через СМС‑код работает, только если сам код нельзя получить бесплатно и без ограничений. Там, где СМС дорого или не доходит, дешевле подтверждать номер звонком (Flash Call) или через Mobile ID оператора — там пользователь вообще не вводит код. Разбор жалоб «код не пришёл» и резервных каналов — на странице не приходит СМС‑код.
Официальные материалы шлюзов, с которыми мы работаем или которые часто выбирают клиенты. Тарифы и набор каналов меняются — сверяйтесь с документацией перед интеграцией.
push_msg, баланс — get_profile, отчёты — get_msg_report. Статусы доставки можно получать на свой URL (настраивается в личном кабинете).POST /api/message для разных каналов; тип задаётся полем route (для Flash Call — fcall). Есть раздел про webhook.config.php.smsaero-api, класс SmsAero, метод send_sms; авторизация по email и API‑ключу.smsaero_init, smsaero_send_sms. Удобно для cron и проверок из консоли.smsc-api: интеграции для Laravel, JavaScript, .NET и других стеков.Пример по документации RedSMS: код — это 4 цифры, которые пользователь увидит в номере входящего звонка. Заголовок secret — MD5 от строки ts + API‑ключ, где ts — любое уникальное значение запроса.
TS=$(date +%s%N) curl -X POST "https://cp.redsms.ru/api/message" \ -H "login: $REDSMS_LOGIN" \ -H "ts: $TS" \ -H "secret: $(echo -n "$TS$REDSMS_API_KEY" | md5sum | awk '{print $1}')" \ -H "Content-type: application/json" \ -d '{"route":"fcall","to":"+79993332211","text":"1234"}'
Для сравнения — обычная СМС с кодом через SMS Просто, теми же параметрами, что использует наш verify.php:
curl -X POST "http://api.sms-prosto.ru/" \ -d method=push_msg -d api_v=2.0 -d format=json \ -d key="$SMS_API_KEY" -d sender_name="$SMS_SENDER" \ -d phone=79991234567 --data-urlencode text="Ваш код: 4827"
Шлюз сам сообщает, дошло ли сообщение, — не нужно опрашивать API по таймеру. По документации SMS Просто присылает GET‑запрос с полями id_sms, state, phone, last_update; RedSMS — POST в формате application/x-www-form-urlencoded с полями uuid, status, status_time и ждёт ответа не дольше 10 секунд. Один обработчик может принимать оба формата:
<?php // Отвечаем быстро: тяжёлую обработку — в очередь if (isset($_GET['id_sms'])) { // SMS Просто: значения state — по таблице в документации save_status('sms-prosto', $_GET['id_sms'], $_GET['state'] ?? ''); } elseif (isset($_POST['uuid'])) { // RedSMS: например status = delivered save_status('redsms', $_POST['uuid'], $_POST['status'] ?? ''); } http_response_code(200); function save_status(string $gw, string $id, string $status): void { // UPDATE otp_messages SET status = ? WHERE gateway = ? AND message_id = ? // message_id — это sms_id / uuid из ответа на отправку }
Что делать со статусом: если через 30–60 секунд после отправки сообщение так и не доставлено, предложите пользователю другой канал — звонок или Mobile ID оператора. RedSMS рекомендует принимать webhook только с их IP‑адресов; раз эндпоинт должен быть доступен без авторизации, фильтр по IP или секретный токен в URL лишним не будет.
Подключим СМС‑верификацию или Flash Call к вашей форме, поставим серверные лимиты от SMS‑бомберов, настроим webhook статусов и передачу заявок в CRM. От вас только доступы.
tildasms.ru — сервис с опытом в настройке рекламы. Решение придумано именно для экономии на рекламе: снижения стоимости лида и борьбы с фродовыми заявками.