Формы оплаты на фронтенде: как не потерять деньги и данные карт
Форма оплаты — точка, где ошибка стоит денег и попадания под PCI DSS. Разбираем, почему нельзя гонять номер карты через свой сервер и как собрать платёж на токенизации.

Разработчик верстает форму с полями «номер карты», «срок» и «CVV», отправляет их POST-запросом на свой бекенд и оттуда — в платёжный шлюз. Через месяц приходит письмо от эквайера: ваш сервис обрабатывает данные карт напрямую, для этого нужен уровень соответствия PCI DSS SAQ D — аудит, сканирование, отдельная инфраструктура. Стоимость соответствия для маленького магазина легко перекрывает годовую выручку. Разбираем, как собрать форму оплаты так, чтобы карта никогда не касалась вашего кода.
Почему очевидное решение — ловушка
Логика «сам собрал форму — сам отправил на шлюз» кажется прямой. Проблема в том, что как только номер карты (PAN) и CVV проходят через ваш JavaScript, ваш домен и ваш сервер, вы попадаете в область действия PCI DSS1. Это не рекомендация, а требование платёжных систем: за приём карт напрямую без соответствия эквайер вправе отключить вас или выставить штраф.
Второй риск — безопасность. Ваш фронтенд подключает десятки сторонних скриптов: аналитику, чаты, A/B-тесты. Любой из них, если скомпрометирован, читает содержимое ваших полей ввода. Атака Magecart работает именно так: злоумышленник внедряет скрипт, который слушает поля формы и сливает номера карт. Если поля живут в вашем DOM — вы уязвимы.
Вывод, к которому приходят все зрелые интеграции: поля с данными карты не должны принадлежать вашей странице. Их отдают провайдеру.
Правило, экономящее месяцы аудита: если вы можете прочитать номер карты из своего JavaScript — вы делаете форму оплаты неправильно.
Три способа отдать поля провайдеру
Все крупные платёжные провайдеры (Stripe, ЮKassa, CloudPayments, Tinkoff) предлагают один из трёх подходов. Различаются они тем, где живёт форма и насколько вы контролируете её вид.
Редирект на страницу провайдера
Вы формируете платёж на бекенде, получаете ссылку и отправляете пользователя на страницу оплаты провайдера. Карта вводится там, вы получаете результат через callback. Самый простой путь и минимальная область PCI (обычно SAQ A). Минус — пользователь уходит с вашего сайта, дизайн формы не ваш.
Виджет / iframe провайдера
Провайдер даёт готовый виджет, который встраивается на вашу страницу через iframe. Поля ввода принадлежат домену провайдера, ваш JavaScript до них не дотягивается — политика одного источника не даёт. Пользователь не уходит с сайта, а карта всё равно вне вашей зоны. Компромисс между контролем и безопасностью.
Отдельные поля-фреймы (hosted fields)
Каждое поле — микро-iframe провайдера, но вы стилизуете их и располагаете как хотите. Внешне неотличимо от собственной формы, при этом данные карты по-прежнему не в вашем DOM. Пример — Stripe Elements. Дают максимум контроля над версткой при сохранении низкого уровня PCI.
Общий принцип для всех трёх
Схема одинакова: провайдер превращает данные карты в токен2 на своей стороне, а вам возвращает безопасный идентификатор. Дальше вы работаете только с токеном — списываете, сохраняете для повторных платежей, возвращаете. Номер карты у вас не появляется никогда.
| Подход | Контроль вёрстки | Уход с сайта | Типичный уровень PCI |
|---|---|---|---|
| Редирект | нет | да | SAQ A |
| Виджет / iframe | ограниченный | нет | SAQ A |
| Hosted fields | полный над стилями | нет | SAQ A / A-EP |
Точный уровень SAQ зависит от вашей конфигурации и провайдера — сверяйте по официальным требованиям PCI SSC и документации эквайера, а не по этой таблице как по истине в последней инстанции.
Как это выглядит в коде на примере токенизации
Ниже — принцип на примере Stripe.js (актуальная версия загружается с https://js.stripe.com/v3/). Ключевой момент: публикуемый ключ (publishable key) можно держать во фронтенде, секретный ключ — только на сервере. Форма собирает поля через Elements, а не через обычные <input>.
// Подключение: <script src="https://js.stripe.com/v3/"></script>
// Публикуемый ключ безопасен во фронтенде
const stripe = Stripe('pk_test_YOUR_PUBLISHABLE_KEY');
const elements = stripe.elements();
// Поле карты — это iframe провайдера, а не ваш input
const cardElement = elements.create('card');
cardElement.mount('#card-container');
const form = document.getElementById('payment-form');
form.addEventListener('submit', async (event) => {
event.preventDefault();
// Данные карты уходят напрямую в Stripe, минуя ваш сервер
const { paymentMethod, error } = await stripe.createPaymentMethod({
type: 'card',
card: cardElement,
});
if (error) {
document.getElementById('card-errors').textContent = error.message;
return;
}
// На свой сервер уходит только id платёжного метода, не номер карты
const response = await fetch('/api/pay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paymentMethodId: paymentMethod.id }),
});
// Дальше сервер списывает средства секретным ключом
});
Точные имена методов и параметров сверяйте с документацией вашей версии SDK — у Stripe за последние годы менялся рекомендованный поток (от createToken к createPaymentMethod и Payment Intents). У ЮKassa и CloudPayments API другой, но принцип тот же: карта не касается вашего кода.
Что почти всегда делают неправильно
- Валидируют CVV своим кодом. Если вы написали регулярку для CVV — значит, CVV прошёл через ваш JavaScript. Валидацию делает провайдерский элемент.
- Логируют тело запроса. Middleware пишет весь POST в лог «на всякий случай». Если туда попал PAN — лог стал носителем данных карты со всеми последствиями.
- Считают сумму на клиенте. Итог заказа приходит с фронтенда, сервер верит ему на слово. Пользователь меняет 10000 на 10 в DevTools. Сумму всегда пересчитывает бекенд по id товаров.
- Забывают про идемпотентность. Пользователь дважды нажал «Оплатить», ушли два списания. Провайдеры дают ключ идемпотентности — используйте его на серверном запросе.
- Подключают шлюз по HTTP или без CSP. Content-Security-Policy должна ограничивать, откуда грузятся скрипты. Без неё внедрённый сторонний код читает форму.
Обработка результата: почему нельзя верить фронтенду
Частая ошибка — считать платёж успешным, потому что фронтенд получил «ок» и отрисовал спасибо-страницу. Пользователь мог закрыть вкладку до подтверждения, деньги списаться позже, платёж уйти в 3-D Secure. Единственный надёжный источник статуса — вебхук от провайдера на ваш сервер. Фронтенд показывает предварительный результат, заказ подтверждается только по вебхуку.
3-D Secure — отдельный подводный камень. Банк может потребовать подтверждение, и поток прервётся редиректом на страницу банка. Ваш код должен уметь вернуть пользователя обратно и дождаться финального статуса, а не считать платёж завершённым до 3DS.
Что настроить после запуска формы
- Проверьте заголовок
Content-Security-Policy: вscript-srcиframe-srcдолжны быть только домены вашего провайдера и ничего лишнего. - Настройте проверку подписи вебхуков — провайдер подписывает их, и без проверки подписи любой может подделать «оплата прошла».
- Пройдите по логам и уберите запись тела платёжных запросов.
- Определите свой уровень PCI SAQ по опроснику эквайера — даже с токенизацией минимальный уровень заполнить нужно.
- Протестируйте отказные и 3DS-сценарии на тестовых картах провайдера, а не только «happy path».
Инвалидация и хранение сохранённых карт для повторных списаний — отдельная тема; здесь мы рассматривали только первичный ввод и токенизацию.
Разрушитель авто-разводов: Фото заказ-наряда СТО: завышения, дубли работ, лишние позиции.
Спросить за 15 ₽Источники: PCI Security Standards Council — официальные документы и SAQ, Stripe Docs — Payment Element и приём платежей
Частые вопросы
А если я всё-таки хочу собрать поля сам и просто зашифровать номер карты перед отправкой?
У меня другой провайдер, не Stripe. Пример подойдёт?
Форма не отправляется, в консоли ошибка про CSP. Что делать?
Нужен ли мне PCI DSS вообще, если карту обрабатывает провайдер?
Пользователь оплатил, но заказ не подтвердился. Почему?
Можно ли хранить сохранённую карту для повторных списаний?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.