Como lidar com o 3D Secure numa aplicação de página única (SPA)
Gerencie o 3D Secure em um app de página única com Stripe: preserve o estado do checkout, reconstrua a rota de retorno e verifique o PaymentIntent no servidor.
Para lidar com o 3D Secure numa aplicação de página única, guarde o carrinho e o ID do PaymentIntent antes de redirecionar o cliente para o banco, encaminhe-o de volta para uma rota de retorno dedicada, reconstrua o estado do checkout a partir do seu servidor nessa rota e confirme o resultado do pagamento no servidor antes de processar qualquer encomenda.
O erro costuma manifestar-se assim: um cliente toca em “Pagar”, é encaminhado para o banco, regressa e depara-se com um carrinho vazio ou com um indicador de carregamento que nunca termina. Alguns clientes acabam por pagar uma segunda vez. Este artigo aborda as regras do URL de retorno na Payment Intents API da Stripe, a opção de iframe, a confirmação do lado do servidor e os cartões de teste necessários para exercitar cada fluxo.
Pontos-chave
- Um redirecionamento de página inteira para o banco descarrega a sua SPA, pelo que qualquer estado do carrinho ou do checkout mantido apenas em memória desaparece quando o cliente regressa.
- A Stripe acrescenta
payment_intentepayment_intent_client_secretao seureturn_url. Estes parâmetros indicam qual é o PaymentIntent, não se o pagamento foi bem-sucedido. - A rota de retorno deve ler o ID com
URLSearchParams, pedir o estado ao seu servidor e nunca apresentar um botão de pagamento para um intent que já foi bem-sucedido. - Um iframe de 3D Secure não pode ter o atributo
sandbox, e a sua CSP tem de permitir frames dehttps://js.stripe.com,https://hooks.stripe.come da origem do seureturn_url. - Processe uma encomenda apenas após uma consulta (retrieve) do PaymentIntent do lado do servidor ou de um webhook
payment_intent.succeeded.
O que é o 3D Secure?
O 3D Secure (3DS) é uma verificação que o emissor do cartão executa durante um pagamento online para confirmar que é o titular do cartão quem está a pagar. Por vezes, acontece em segundo plano. Noutros casos, o cliente tem de intervir, por exemplo introduzindo um código de utilização única enviado para o telemóvel. O guia de Autenticação Forte do Cliente da Stripe explica que a SCA é uma regra do Reino Unido e da Europa para pagamentos iniciados pelo cliente, e a página de preparação para a SCA da Stripe indica o 3D Secure como a forma de os pagamentos com cartão cumprirem essa regra. Na prática, isto torna o 3DS obrigatório na maioria dos pagamentos online com cartão em que tanto a empresa como o emissor do cartão se encontram no EEE ou no Reino Unido.
As carteiras digitais são a exceção. O guia de autenticação 3DS da Stripe apresenta as carteiras digitais e os pagamentos fora de sessão (off-session) como exemplos de transações que não suportam 3DS. Essa é uma das razões pelas quais um fluxo de carteira nativo do browser, como a Payment Request API, normalmente dispensa o passo de desafio.
Porque é que as SPAs perdem o estado durante o 3D Secure?
Uma aplicação de página única perde o estado durante o 3D Secure porque o checkout sai da página. Ou o cliente é encaminhado para o site do banco, ou a Stripe envia-o para o seu return_url quando a confirmação termina. Quando regressa, a aplicação tem de continuar a partir do ponto em que parou. O estado do router, a store e a árvore de componentes desapareceram todos. O que resta é um arranque a frio (cold boot) no seu return_url.
Mesmo em caso de sucesso, o cliente sai da página. Por predefinição, o stripe.confirmPayment envia o cliente para o seu return_url assim que a confirmação termina, pelo que a respetiva Promise nunca chega a ser resolvida nessa página. O código a seguir ao seu await nunca é executado.
Persistir o estado e definir um return_url dedicado
Quando um redirecionamento 3D Secure da Stripe termina, o cliente chega ao seu return_url com dois parâmetros de query, payment_intent e payment_intent_client_secret. Estes identificam o PaymentIntent, mas não dizem nada sobre o sucesso do pagamento. O return_url é a página para onde a Stripe reencaminha o cliente. Aponte-o para uma rota que exista exclusivamente para este fim.
Com o Payment Element, o stripe.confirmPayment apresenta o 3DS numa caixa de diálogo ou envia o cliente para o banco. Por predefinição, efetua depois um redirecionamento de página inteira para o seu return_url quando a confirmação é concluída. Para evitar esse redirecionamento em pagamentos com cartão, passe redirect: 'if_required'. Os métodos de pagamento baseados em redirecionamento continuam a sair da página e, nesse caso, terá de tratar o resultado bem-sucedido no código.
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();
}
}
O código só avança para lá do await se a confirmação falhar de imediato. Nesse caso, o cliente continua na página, por isso mostre o erro e volte a ativar o formulário. Os dados no sessionStorage sobrevivem a navegações e recarregamentos dentro do mesmo separador. Utilize-o como uma referência para o carrinho e mantenha o carrinho propriamente dito no servidor.
Construir a rota de retorno a partir do servidor
A rota de retorno lê o ID do PaymentIntent da query string, remove o client secret da barra de endereço e pergunta ao backend o que aconteceu. Nada do que apresenta provém da memória. Faça o parsing da query com URLSearchParams. Não extraia a substring a seguir ao primeiro =, porque isso deixa de funcionar assim que o URL contiver um segundo parâmetro.
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;
}
Se a query string tiver desaparecido, por exemplo após um recarregamento, a rota recorre à referência guardada no sessionStorage. O history.replaceState remove o secret da barra de endereço e da entrada atual do histórico. Execute-o cedo, antes de os scripts de analytics ou de session replay lerem o URL, para que nunca o registem. Associe cada estado a um ecrã:
| Estado | Ecrã de retorno |
|---|---|
succeeded | Confirmação da encomenda; limpar a chave pendente |
requires_capture | Confirmação (se autorizar e capturar separadamente) |
processing | ”A confirmar o pagamento”; depois, consultar novamente o servidor (polling) |
requires_payment_method | Pagamento falhado; reconstruir o carrinho e pedir outro cartão |
requires_action | O cliente pode ainda estar a autenticar-se ou pode ter saído; oferecer a opção de retomar |
canceled | Pagamento cancelado; iniciar um novo checkout |
Verifique o estado do intent existente antes de oferecer um novo pagamento. Uma rota de retorno que se reconstrói a partir da memória pode apresentar um novo botão “Pagar” para um intent que já foi bem-sucedido. A falha ocorre ao longo de um descarregamento de página, pelo que os registos de erros raramente relacionam o checkout, a passagem pelo banco e o retorno. O session replay da visita de retorno mostra o que o cliente realmente viu: um carrinho vazio, um indicador de carregamento que nunca termina ou um segundo botão de pagamento.
Deve usar um redirecionamento ou um iframe para o 3D Secure?
O redirecionamento de página inteira é a opção predefinida e a mais simples. A abordagem com iframe mantém a SPA carregada, mas exige mais desenvolvimento da sua parte e só funciona para pagamentos com cartão. Na abordagem com iframe, confirma o pagamento com o tratamento automático de ações desativado, lê o next_action (o passo que, segundo a Stripe, o cliente tem de concluir) e carrega o next_action.redirect_to_url.url num frame.
| Redirecionamento | Iframe | |
|---|---|---|
| Onde ocorre a autenticação | Página do banco, nível superior | Página do banco dentro do seu modal |
| A SPA é descarregada | Sim | Não |
| O que tem de construir | Rota de retorno | Frame, página de postMessage, listener |
| Impacto na CSP | Nenhum para o frame | Entradas frame-src |
| Alternativa (fallback) | Não é necessária | Redirecionamento |
O excerto abaixo converte os dados do cartão do Payment Element num PaymentMethod e, em seguida, confirma o pagamento com o tratamento de 3DS da própria Stripe desativado. Para que isto funcione, crie a instância de Elements com paymentMethodCreation: 'manual'. A referência de Elements da Stripe indica que é esta opção que permite ao stripe.createPaymentMethod criar um PaymentMethod a partir do Payment Element. Também tem de chamar primeiro o elements.submit(), que valida o formulário.
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);
}
A página /checkout/3ds-done executa window.top?.postMessage('3ds-complete', window.location.origin). A página principal compara o event.origin com a sua própria origem antes de agir, tal como recomendam as orientações da MDN sobre postMessage. Em seguida, remove o frame e pede o estado ao seu servidor.
Não coloque o atributo sandbox no iframe do 3D Secure. O emissor do cartão controla parte do conteúdo carregado nesse frame e o guia de 3DS da Stripe refere que algumas páginas de emissores deixam de funcionar quando estão em sandbox, o que faz com que o pagamento falhe. Se enviar uma Content Security Policy, a diretiva frame-src tem de permitir https://js.stripe.com, https://hooks.stripe.com e a origem do seu return_url. Disponibilize também uma alternativa aos clientes: um link “Abrir página do banco” que chame window.location.assign(url). Nesse caso, o banco envia-os para /checkout/3ds-done como página inteira, pelo que essa página deve verificar se window.top === window e, em caso afirmativo, reencaminhar para /checkout/return com a mesma query string. O cliente passa então pela mesma rota de retorno do fluxo de redirecionamento.
Porque deve confirmar o resultado do lado do servidor?
Chegar ao seu return_url após o 3D Secure não diz nada sobre o resultado do pagamento. Qualquer pessoa pode escrever uma query string nesse URL. O processamento da encomenda deve depender exclusivamente de uma consulta do lado do servidor ou de um webhook.
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 });
});
Antes de responder, confirme que o intent pertence ao carrinho da sessão atual. O guia sobre atualizações do estado de pagamento da Stripe recomenda que o processamento das encomendas seja orientado por webhooks em vez de polling. Fique à escuta do payment_intent.succeeded para processar a encomenda e do payment_intent.payment_failed para informar o cliente. O guia de webhooks da OpenReplay aborda o lado do endpoint. Uma verificação 3DS bem-sucedida transfere normalmente para o emissor do cartão o custo das disputas por fraude elegíveis, mas a Stripe deixa claro que isso nunca é garantido.
Como testar o fluxo de 3D Secure?
Os cartões de teste da Stripe acionam cada um dos cenários. Utilize qualquer CVC, qualquer código postal e qualquer data de validade futura:
| Cartão | Comportamento |
|---|---|
4000000000003220 | Exige sempre 3DS2 |
4000008400001629 | 3DS obrigatório; depois, recusado com card_declined |
4000000000003055 | 3DS suportado, mas não obrigatório |
4242424242424242 | 3DS suportado, mas o cartão não está inscrito, pelo que não há pedido de autenticação |
Com chaves de teste, a Stripe apresenta uma página bancária fictícia com botões para aprovar ou reprovar a verificação. Teste através do seu próprio frontend. Os pagamentos criados no Stripe Dashboard ignoram o redirecionamento 3DS. Para cada cartão, recarregue a rota de retorno depois de esta ter sido carregada. Deve continuar a apresentar o ecrã correto.
Conclusão
Numa SPA, a passagem para o 3D Secure é uma navegação e a rota de retorno é um arranque a frio. Guarde uma referência para o carrinho antes de confirmar, reconstrua o estado a partir do servidor quando o cliente regressar e trate o redirecionamento como um pedido para verificar o estado, nunca como prova de pagamento. Como próximo passo, adicione uma rota /checkout/return, teste-a com os quatro cartões de teste e verifique que nenhum deles apresenta um botão de pagamento para um intent bem-sucedido.
Perguntas frequentes
Qual é a diferença entre os fluxos sem fricção (frictionless) e com desafio (challenge) no 3D Secure 2?
No fluxo sem fricção, o emissor autentica o pagamento em segundo plano e o cliente não vê qualquer passo adicional. No fluxo com desafio, o cliente tem de intervir, por exemplo introduzindo um código de utilização única. O guia de SCA da Stripe refere que uma verificação 3DS sem fricção bem-sucedida continua a transferir a responsabilidade por fraude para o emissor. Quando, em vez disso, é aplicada uma isenção de SCA, a responsabilidade pelas disputas por fraude permanece com a empresa.
Como forçar o 3D Secure num pagamento Stripe?
Defina payment_method_options[card][request_three_d_secure] como 'any' ou 'challenge' ao criar ou confirmar o PaymentIntent. Normalmente, a Stripe utiliza o Radar para decidir quando pedir o 3DS com base no risco. Com este parâmetro definido, a Stripe tenta aplicar o 3DS a esse pagamento e as suas regras dinâmicas de 3DS do Radar deixam de se aplicar ao mesmo. O valor 'any' favorece um fluxo sem fricção e 'challenge' favorece um desafio, mas a decisão final cabe ao emissor. A Stripe indica que o acionamento manual se destina a equipas que utilizam o seu próprio motor de deteção de fraude.
O que deve acontecer se um cliente abandonar o checkout durante o 3D Secure?
Quando o cliente regressar, reutilize o PaymentIntent existente. Não crie um novo. A recomendação da Stripe é continuar a utilizar o mesmo PaymentIntent quando um checkout interrompido é retomado e atualizar o respetivo montante caso o carrinho tenha sido alterado. Uma autenticação abandonada deixa normalmente o intent no estado requires_action. Verifique primeiro o estado no seu servidor e, se já indicar succeeded ou processing, apresente o estado da encomenda em vez de um formulário de pagamento.
É seguro a Stripe colocar o client secret no URL de retorno?
Trate o client secret como informação sensível. A referência da API da Stripe alerta que o secret é suficiente para concluir o pagamento a partir de um browser, pelo que apenas o cliente o deve ver, e nunca o deve armazenar nem registar em logs. Leia o parâmetro payment_intent e, em seguida, remova a query string com history.replaceState. Faça-o cedo, antes de os scripts de analytics ou de session replay lerem o URL, para que nunca registem o secret. Disponibilize as páginas de checkout através de TLS.