Как обрабатывать 3D Secure в одностраничном приложении
Обрабатывайте 3D Secure в одностраничном приложении с Stripe: сохраняйте состояние оплаты, восстанавливайте страницу возврата и проверяйте PaymentIntent на сервере.
Чтобы корректно обработать 3D Secure в одностраничном приложении (SPA), сохраните корзину и ID PaymentIntent перед переходом в банк и возвращайте клиента на отдельный маршрут. На этом маршруте восстанавливайте состояние оформления заказа с сервера, а результат платежа проверяйте на сервере до того, как приступать к выполнению заказа.
Обычно ошибка проявляется так: клиент нажимает «Оплатить», переходит на страницу банка, возвращается и видит пустую корзину или бесконечный спиннер. Некоторые клиенты после этого платят повторно. В статье рассматриваются правила работы с return URL в Payment Intents API от Stripe, вариант с iframe, подтверждение на стороне сервера и тестовые карты для проверки каждого сценария.
Ключевые выводы
- Полностраничный редирект в банк выгружает SPA, поэтому состояние корзины или оформления заказа, хранящееся только в памяти, к возвращению клиента теряется.
- Stripe добавляет к вашему
return_urlпараметрыpayment_intentиpayment_intent_client_secret. Они указывают, о каком PaymentIntent идёт речь, но не сообщают, прошёл ли платёж. - Маршрут возврата должен считывать ID через
URLSearchParams, запрашивать статус у вашего сервера и никогда не показывать кнопку оплаты для уже успешного intent. - У iframe для 3D Secure не должно быть атрибута
sandbox. Ваша CSP должна разрешать фреймы сhttps://js.stripe.com,https://hooks.stripe.comи с origin вашегоreturn_url. - Выполняйте заказ только после серверного запроса PaymentIntent (retrieve) или получения вебхука
payment_intent.succeeded.
Что такое 3D Secure?
3D Secure (3DS) — это проверка, которую банк-эмитент проводит во время онлайн-платежа, чтобы убедиться, что платит именно держатель карты. Иногда она проходит в фоновом режиме. В других случаях от клиента требуется действие, например ввод одноразового кода из SMS. В руководстве Stripe по строгой аутентификации клиентов поясняется, что SCA (Strong Customer Authentication) — это требование регуляторов Великобритании и Европы к платежам, инициированным клиентом. На странице Stripe о готовности к SCA 3D Secure названа способом, которым карточные платежи выполняют это требование. На практике 3DS обязательна для большинства онлайн-платежей картами, если и компания, и банк-эмитент находятся в ЕЭЗ или Великобритании.
Исключение составляют кошельки. В руководстве Stripe по аутентификации 3DS кошельки и платежи вне сессии (off-session) приведены как примеры транзакций, которые не поддерживают 3DS. Поэтому нативные браузерные сценарии оплаты через кошелёк, такие как Payment Request API, обычно обходятся без этапа дополнительной проверки (challenge).
Почему SPA теряют состояние во время 3D Secure?
Одностраничное приложение теряет состояние во время 3D Secure, потому что процесс оплаты уходит со страницы. Клиент либо переходит на сайт своего банка, либо Stripe отправляет его на ваш return_url по завершении подтверждения. Когда клиент возвращается, приложение должно продолжить с того места, где остановилось. Но состояние роутера, стор и дерево компонентов к этому моменту уже утрачены. Вместо них происходит холодный старт приложения на вашем return_url.
Успешная оплата тоже уводит со страницы. По умолчанию stripe.confirmPayment отправляет клиента на return_url сразу после завершения подтверждения, поэтому его Promise на этой странице так и не разрешается. Код после await не выполняется.
Сохраните состояние и задайте отдельный return_url
После редиректа 3D Secure в Stripe клиент попадает на ваш return_url с двумя query-параметрами: payment_intent и payment_intent_client_secret. Они идентифицируют PaymentIntent, но ничего не говорят о том, прошёл ли платёж. return_url — это страница, на которую Stripe возвращает клиента. Направьте его на маршрут, который существует только для этой цели.
При использовании Payment Element метод stripe.confirmPayment либо показывает 3DS в диалоговом окне, либо отправляет клиента в его банк. По умолчанию после завершения подтверждения он выполняет полностраничный редирект на ваш return_url. Чтобы обойтись без этого редиректа для карт, передайте redirect: 'if_required'. Способы оплаты, основанные на редиректе, всё равно уводят со страницы, а успешный результат вам придётся обрабатывать в коде.
import type { Stripe, StripeElements } from '@stripe/stripe-js';
const PENDING_KEY = 'checkout:pending';
export async function pay(
stripe: Stripe,
elements: StripeElements,
cartId: string,
paymentIntentId: string,
): Promise<void> {
sessionStorage.setItem(PENDING_KEY, JSON.stringify({ cartId, paymentIntentId }));
const { error } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: `${window.location.origin}/checkout/return` },
});
if (error) {
showError(error.message ?? 'Payment failed');
enableForm();
}
}
Выполнение доходит до кода после await только при немедленной ошибке подтверждения. В этом случае клиент остаётся на странице, поэтому покажите ошибку и снова активируйте форму. Данные в sessionStorage сохраняются при переходах и перезагрузках в пределах одной вкладки. Храните там только ссылку на корзину, а саму корзину держите на сервере.
Восстанавливайте маршрут возврата с сервера
Маршрут возврата считывает ID PaymentIntent из query-строки, убирает client secret из адресной строки и запрашивает у бэкенда результат. Ничего из того, что он отображает, не берётся из памяти. Разбирайте query-строку с помощью URLSearchParams. Не берите подстроку после первого =: такой подход перестаёт работать, как только в URL появляется второй параметр.
type PiStatus =
| 'succeeded' | 'processing' | 'requires_capture'
| 'requires_payment_method' | 'requires_action'
| 'requires_confirmation' | 'canceled';
export async function loadReturnState(): Promise<PiStatus | null> {
const params = new URLSearchParams(window.location.search);
const pending = JSON.parse(sessionStorage.getItem(PENDING_KEY) ?? 'null') as
| { paymentIntentId: string }
| null;
const id = params.get('payment_intent') ?? pending?.paymentIntentId;
if (!id) return null;
history.replaceState(null, '', window.location.pathname);
const res = await fetch(`/api/payments/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error(`Status lookup failed: ${res.status}`);
const { status } = (await res.json()) as { status: PiStatus };
return status;
}
Если query-строки нет, например после перезагрузки страницы, маршрут использует ссылку, сохранённую в sessionStorage. history.replaceState удаляет секрет из адресной строки и из текущей записи истории. Вызывайте его как можно раньше, до того как URL прочитают скрипты аналитики или записи сессий, чтобы секрет не попал в их данные. Сопоставьте каждый статус с экраном:
| Статус | Экран возврата |
|---|---|
succeeded | Подтверждение заказа, удалить ключ ожидающего платежа |
requires_capture | Подтверждение (если авторизация и списание выполняются раздельно) |
processing | «Подтверждаем платёж», затем повторно опросить сервер |
requires_payment_method | Платёж не прошёл: восстановить корзину и запросить другую карту |
requires_action | Клиент может ещё проходить аутентификацию или уже ушёл, поэтому предложите продолжить |
canceled | Платёж отменён: начать новое оформление заказа |
Прежде чем предлагать оплатить снова, проверьте статус существующего intent. Маршрут возврата, восстанавливающий состояние из памяти, может показать новую кнопку «Оплатить» для intent, который уже успешно оплачен. Сбой происходит при выгрузке страницы, поэтому в логах ошибок оформление заказа, переход в банк и возврат редко связаны между собой. Запись сессии (session replay) визита на страницу возврата показывает, что клиент видел на самом деле: пустую корзину, бесконечный спиннер или повторную кнопку оплаты.
Что выбрать для 3D Secure: редирект или iframe?
Полностраничный редирект используется по умолчанию и проще всего в реализации. Вариант с iframe сохраняет SPA загруженным, но требует больше собственной разработки и работает только для карточных платежей. В этом варианте вы выполняете подтверждение с отключённой автоматической обработкой действий, считываете next_action (шаг, который, по данным Stripe, должен выполнить клиент) и загружаете next_action.redirect_to_url.url во фрейм.
| Редирект | Iframe | |
|---|---|---|
| Где проходит аутентификация | Страница банка, верхний уровень | Страница банка внутри вашего модального окна |
| Выгружается ли SPA | Да | Нет |
| Что нужно реализовать | Маршрут возврата | Фрейм, страницу с postMessage, обработчик событий |
| Влияние на CSP | Для фрейма — никакого | Записи в frame-src |
| Запасной вариант | Не нужен | Редирект |
Приведённый ниже фрагмент превращает данные карты из Payment Element в PaymentMethod, а затем выполняет подтверждение с отключённой встроенной обработкой 3DS в Stripe. Для этого экземпляр Elements нужно создать с параметром paymentMethodCreation: 'manual'. Согласно справочнику Stripe по Elements, именно этот параметр позволяет stripe.createPaymentMethod создать PaymentMethod из Payment Element. Кроме того, сначала необходимо вызвать elements.submit(), который проверяет форму.
const elements = stripe.elements({ clientSecret, paymentMethodCreation: 'manual' });
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');
// When the customer clicks "Pay":
const { error: submitError } = await elements.submit();
if (submitError) {
showError(submitError.message ?? 'Check your card details');
return;
}
const { paymentMethod, error: pmError } = await stripe.createPaymentMethod({ elements });
if (pmError) {
showError(pmError.message ?? 'Payment failed');
return;
}
const { paymentIntent, error } = await stripe.confirmCardPayment(
clientSecret,
{ payment_method: paymentMethod.id, return_url: `${location.origin}/checkout/3ds-done` },
{ handleActions: false },
);
if (error) {
showError(error.message ?? 'Payment failed');
enableForm();
return;
}
const action = paymentIntent?.next_action;
if (paymentIntent?.status === 'requires_action' && action?.redirect_to_url?.url) {
const frame = document.createElement('iframe');
frame.src = action.redirect_to_url.url;
frame.width = '600';
frame.height = '400';
container.appendChild(frame);
}
Страница /checkout/3ds-done выполняет window.top?.postMessage('3ds-complete', window.location.origin). Родительская страница, прежде чем реагировать, сверяет event.origin со своим origin, как рекомендует руководство MDN по postMessage. Затем она удаляет фрейм и запрашивает статус у вашего сервера.
Не добавляйте атрибут sandbox к iframe для 3D Secure. Часть содержимого этого фрейма контролирует банк-эмитент, и, как отмечается в руководстве Stripe по 3DS, некоторые страницы эмитентов в режиме sandbox перестают работать, из-за чего платёж не проходит. Если вы отправляете заголовок Content Security Policy, его директива frame-src должна разрешать https://js.stripe.com, https://hooks.stripe.com и origin вашего return_url.
Также предусмотрите для клиентов запасной путь: ссылку «Открыть страницу банка», которая вызывает window.location.assign(url). В этом случае банк отправит клиента на /checkout/3ds-done как на полноценную страницу. Поэтому эта страница должна проверять условие window.top === window и, если оно выполняется, перенаправлять на /checkout/return с той же query-строкой. Так клиент пройдёт через тот же маршрут возврата, что и в сценарии с редиректом.
Почему результат нужно проверять на стороне сервера?
Само по себе попадание на return_url после 3D Secure ничего не говорит о результате платежа: любой может вручную подставить query-строку в этот URL. Выполнение заказа должно зависеть только от серверного запроса (retrieve) или вебхука.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
app.get('/api/payments/:id', async (req, res) => {
const intent = await stripe.paymentIntents.retrieve(req.params.id);
res.json({ status: intent.status });
});
Перед отправкой ответа убедитесь, что intent относится к корзине текущей сессии. Руководство Stripe по обновлениям статуса платежей рекомендует запускать выполнение заказов по вебхукам, а не по результатам опроса. Отслеживайте payment_intent.succeeded, чтобы выполнить заказ, и payment_intent.payment_failed, чтобы уведомить клиента. О реализации эндпоинта рассказывает руководство OpenReplay по вебхукам. Успешная проверка 3DS обычно переносит ответственность по соответствующим спорам о мошенничестве на банк-эмитент, однако Stripe прямо указывает, что это никогда не гарантируется.
Как протестировать сценарий 3D Secure?
Тестовые карты Stripe позволяют проверить каждую ветку. Указывайте любой CVC, любой почтовый индекс и любой срок действия в будущем:
| Карта | Поведение |
|---|---|
4000000000003220 | Всегда требует 3DS2 |
4000008400001629 | Требует 3DS, затем платёж отклоняется с кодом card_declined |
4000000000003055 | Поддерживает 3DS, но не требует её |
4242424242424242 | Поддерживает 3DS, но карта не зарегистрирована в программе, поэтому запроса не будет |
При использовании тестовых ключей Stripe показывает имитацию страницы банка с кнопками для успешного или неуспешного прохождения проверки. Тестируйте через собственный фронтенд: платежи, созданные в Stripe Dashboard, обходят редирект 3DS. Для каждой карты после загрузки маршрута возврата перезагрузите его ещё раз. Он по-прежнему должен показывать правильный экран.
Заключение
В SPA переход в банк для 3D Secure — это навигация, а маршрут возврата — холодный старт. Перед подтверждением сохраните ссылку на корзину, при возвращении клиента восстанавливайте состояние с сервера и воспринимайте редирект как сигнал проверить статус, но никогда как доказательство оплаты. Следующий шаг — добавить маршрут /checkout/return, прогнать через него все четыре тестовые карты и убедиться, что ни в одном случае для успешно оплаченного intent не отображается кнопка оплаты.
Часто задаваемые вопросы
Чем отличаются беспрепятственный сценарий (frictionless) и сценарий с дополнительной проверкой (challenge) в 3D Secure 2?
В беспрепятственном сценарии эмитент аутентифицирует платёж в фоновом режиме, и клиент не видит никаких дополнительных шагов. В сценарии с дополнительной проверкой клиент должен выполнить действие, например ввести одноразовый код. Согласно руководству Stripe по SCA, успешная беспрепятственная проверка 3DS также переносит ответственность за мошенничество на эмитента. Если же вместо проверки применяется исключение из SCA, ответственность по спорам о мошенничестве остаётся на компании.
Как принудительно включить 3D Secure для платежа в Stripe?
При создании или подтверждении PaymentIntent задайте параметру payment_method_options[card][request_three_d_secure] значение 'any' или 'challenge'. Обычно Stripe с помощью Radar сам решает, когда запрашивать 3DS, исходя из оценки риска. Если параметр задан, Stripe пытается провести 3DS для этого платежа, и ваши правила Radar для динамической 3DS к нему больше не применяются. Значение 'any' склоняет к беспрепятственному сценарию, а 'challenge' — к сценарию с дополнительной проверкой, но окончательное решение принимает эмитент. По словам Stripe, ручной запуск предназначен для команд, использующих собственную систему защиты от мошенничества.
Что делать, если клиент бросил оформление заказа во время 3D Secure?
Когда клиент вернётся, используйте существующий PaymentIntent, а не создавайте новый. Stripe рекомендует продолжать работу с тем же PaymentIntent при возобновлении прерванного оформления заказа и обновлять его сумму, если корзина изменилась. Прерванная аутентификация обычно оставляет intent в статусе requires_action. Сначала проверьте его статус на сервере, и если он уже succeeded или processing, покажите состояние заказа вместо формы оплаты.
Безопасно ли, что Stripe передаёт client secret в URL возврата?
Относитесь к client secret как к конфиденциальным данным. В справочнике API Stripe предупреждается, что секрета достаточно, чтобы завершить платёж из браузера, поэтому видеть его должен только клиент, а хранить или записывать его в логи нельзя. Считайте параметр payment_intent, а затем удалите query-строку с помощью history.replaceState. Делайте это как можно раньше, до того как URL прочитают скрипты аналитики или записи сессий, чтобы секрет не попал в их данные. Отдавайте страницы оформления заказа только по TLS.