12k
All articles

Cómo gestionar 3D Secure en una aplicación de página única

Gestione 3D Secure en una aplicación de página única con Stripe: conserve el estado del pago, reconstruya la ruta de retorno y verifique el PaymentIntent en el servidor.

OpenReplay Team
OpenReplay Team
Cómo gestionar 3D Secure en una aplicación de página única

Para gestionar 3D Secure en una aplicación de página única (SPA), guarda el carrito y el ID del PaymentIntent antes de redirigir al cliente al banco. Envíalo de vuelta a una ruta de retorno dedicada y reconstruye en ella el estado del checkout a partir de tu servidor. Antes de completar cualquier pedido, confirma el resultado del pago en el servidor.

El error suele manifestarse así: un cliente pulsa «Pagar», pasa por la página de su banco, regresa y se encuentra con un carrito vacío o con un indicador de carga que nunca termina. Algunos clientes acaban pagando una segunda vez. Este artículo cubre las reglas de la URL de retorno en la API Payment Intents de Stripe, la opción del iframe, la confirmación del lado del servidor y las tarjetas de prueba necesarias para probar cada flujo.

Puntos clave

  • Una redirección de página completa hacia el banco descarga tu SPA, por lo que cualquier estado del carrito o del checkout que solo esté en memoria desaparece cuando el cliente regresa.
  • Stripe añade payment_intent y payment_intent_client_secret a tu return_url. Estos parámetros indican de qué PaymentIntent se trata, no si el pago se completó correctamente.
  • La ruta de retorno debe leer el ID con URLSearchParams, consultar el estado a tu servidor y no mostrar nunca un botón de pago para un intent que ya se ha completado.
  • Un iframe de 3D Secure no debe tener el atributo sandbox, y tu CSP debe permitir frames de https://js.stripe.com, https://hooks.stripe.com y del origen de tu return_url.
  • Completa un pedido solo después de recuperar el PaymentIntent en el servidor o de recibir un webhook payment_intent.succeeded.

¿Qué es 3D Secure?

3D Secure (3DS) es una verificación que el emisor de la tarjeta realiza durante un pago en línea para confirmar que quien paga es el titular de la tarjeta. A veces ocurre en segundo plano. Otras veces el cliente tiene que intervenir, por ejemplo, introduciendo un código de un solo uso enviado a su teléfono. La guía de autenticación reforzada de clientes de Stripe explica que la SCA es una normativa del Reino Unido y de Europa para los pagos iniciados por el cliente. Por su parte, la página de Stripe sobre preparación para la SCA señala 3D Secure como el mecanismo con el que los pagos con tarjeta cumplen esta normativa. En la práctica, esto hace que 3DS sea obligatorio en la mayoría de los pagos en línea con tarjeta cuando tanto la empresa como el emisor de la tarjeta se encuentran en el EEE o en el Reino Unido.

Las billeteras digitales son la excepción. La guía de autenticación 3DS de Stripe incluye las billeteras digitales y los pagos fuera de sesión (off-session) como ejemplos de transacciones que no admiten 3DS. Esa es una de las razones por las que un flujo de billetera nativo del navegador, como la Payment Request API, suele omitir el paso de verificación.

¿Por qué las SPA pierden el estado durante 3D Secure?

Una aplicación de página única pierde el estado durante 3D Secure porque el checkout abandona la página. O bien el cliente va al sitio de su banco, o bien Stripe lo envía a tu return_url cuando finaliza la confirmación. Al regresar, tu aplicación tiene que continuar desde donde se quedó. Sin embargo, el estado del router, el store y el árbol de componentes han desaparecido. Lo que recibes es un arranque en frío en tu return_url.

Un pago correcto también abandona la página. De forma predeterminada, stripe.confirmPayment envía al cliente a tu return_url en cuanto finaliza la confirmación, por lo que su Promise nunca se resuelve en esa página. El código posterior a tu await nunca se ejecuta.

Persiste el estado y define una return_url dedicada

Cuando finaliza una redirección de 3D Secure de Stripe, el cliente llega a tu return_url con dos parámetros de consulta: payment_intent y payment_intent_client_secret. Estos identifican el PaymentIntent, pero no indican nada sobre si el pago se completó correctamente. La return_url es la página a la que Stripe devuelve al cliente. Haz que apunte a una ruta que exista exclusivamente para este fin.

Con el Payment Element, stripe.confirmPayment muestra 3DS en un cuadro de diálogo o envía al cliente a su banco. De forma predeterminada, después realiza una redirección de página completa a tu return_url una vez finalizada la confirmación. Para evitar esa redirección en los pagos con tarjeta, pasa redirect: 'if_required'. Los métodos de pago basados en redirección seguirán abandonando la página, y tendrás que gestionar el resultado correcto en el 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();
  }
}

El código solo continúa después del await si la confirmación falla de inmediato. En ese caso, el cliente sigue en la página, así que muestra el error y vuelve a habilitar el formulario. Los datos de sessionStorage se conservan entre navegaciones y recargas dentro de la misma pestaña. Úsalo como referencia al carrito y guarda el carrito en sí en el servidor.

Construye la ruta de retorno a partir del servidor

La ruta de retorno lee el ID del PaymentIntent de la cadena de consulta, elimina el client secret de la barra de direcciones y pregunta a tu backend qué ha ocurrido. Nada de lo que renderiza procede de la memoria. Analiza la consulta con URLSearchParams. No tomes la subcadena posterior al primer =, porque deja de funcionar en cuanto la URL incluye un 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;
}

Si la cadena de consulta ha desaparecido, por ejemplo tras una recarga, la ruta recurre a la referencia guardada en sessionStorage. history.replaceState elimina el secret de la barra de direcciones y de la entrada actual del historial. Ejecútalo pronto, antes de que los scripts de analítica o de reproducción de sesiones lean la URL, para que nunca lo registren. Asigna una pantalla a cada estado:

EstadoPantalla de retorno
succeededConfirmación del pedido; elimina la clave pendiente
requires_captureConfirmación (si autorizas y capturas por separado)
processing«Confirmando el pago»; después, vuelve a consultar a tu servidor
requires_payment_methodPago fallido; reconstruye el carrito y solicita otra tarjeta
requires_actionEl cliente puede seguir autenticándose o haber abandonado; ofrécele reanudar
canceledPago cancelado; inicia un nuevo checkout

Comprueba el estado del intent existente antes de ofrecer un nuevo pago. Una ruta de retorno que se reconstruye a partir de la memoria puede mostrar un nuevo botón «Pagar» para un intent que ya se ha completado. El fallo se produce a través de una descarga de página, por lo que los registros de errores rara vez relacionan el checkout, la visita al banco y el retorno. La reproducción de sesiones (session replay) de la visita de retorno muestra lo que el cliente vio realmente: un carrito vacío, un indicador de carga que nunca termina o un segundo botón de pago.

¿Conviene usar una redirección o un iframe para 3D Secure?

La redirección de página completa es la opción predeterminada y la más sencilla. El flujo con iframe mantiene la SPA cargada, pero exige que construyas más piezas por tu cuenta y solo funciona con pagos con tarjeta. En el flujo con iframe, confirmas con la gestión automática de acciones desactivada, lees next_action (el paso que, según Stripe, debe completar el cliente) y cargas next_action.redirect_to_url.url en un frame.

RedirecciónIframe
Dónde se realiza la autenticaciónPágina del banco, a nivel superiorPágina del banco dentro de tu modal
La SPA se descargaSíNo
Qué debes construirRuta de retornoFrame, página de postMessage, listener
Impacto en la CSPNinguno para el frameEntradas frame-src
Alternativa de respaldoNo es necesariaRedirección

El siguiente fragmento convierte los datos de la tarjeta del Payment Element en un PaymentMethod y, a continuación, confirma el pago con la gestión de 3DS propia de Stripe desactivada. Para que funcione, crea la instancia de Elements con paymentMethodCreation: 'manual'. La referencia de Elements de Stripe indica que esta opción es la que permite a stripe.createPaymentMethod crear un PaymentMethod a partir del Payment Element. Además, debes llamar primero a elements.submit(), que valida el formulario.

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);
}

La página /checkout/3ds-done ejecuta window.top?.postMessage('3ds-complete', window.location.origin). La página principal comprueba que event.origin coincide con su propio origen antes de actuar, tal como recomiendan las indicaciones de MDN sobre postMessage. Después, elimina el frame y consulta el estado a tu servidor.

No añadas el atributo sandbox al iframe de 3D Secure. El emisor de la tarjeta controla parte de lo que se carga en ese frame, y la guía de 3DS de Stripe advierte que algunas páginas de emisores dejan de funcionar en un entorno sandbox, lo que hace que el pago falle. Si envías una Content Security Policy, su directiva frame-src debe permitir https://js.stripe.com, https://hooks.stripe.com y el origen de tu return_url. Ofrece también a los clientes una vía alternativa: un enlace «Abrir la página del banco» que llame a window.location.assign(url). En ese caso, el banco los envía a /checkout/3ds-done como página completa, así que haz que esa página compruebe si window.top === window y, de ser así, redirija a /checkout/return con la misma cadena de consulta. De este modo, el cliente pasa por la misma ruta de retorno que en el flujo de redirección.

¿Por qué debes confirmar el resultado en el servidor?

Llegar a tu return_url después de 3D Secure no indica nada sobre el resultado del pago. Cualquiera puede escribir una cadena de consulta en esa URL. La gestión del pedido debe depender únicamente de una consulta en el servidor o de un 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, comprueba que el intent pertenece al carrito de la sesión actual. La guía de Stripe sobre las actualizaciones del estado de los pagos recomienda gestionar los pedidos a partir de webhooks en lugar de mediante sondeo (polling). Escucha payment_intent.succeeded para completar el pedido y payment_intent.payment_failed para informar al cliente. La guía de webhooks de OpenReplay cubre la parte del endpoint. Una verificación 3DS correcta suele trasladar al emisor de la tarjeta el coste de las disputas por fraude elegibles, pero Stripe deja claro que esto nunca está garantizado.

¿Cómo se prueba el flujo de 3D Secure?

Las tarjetas de prueba de Stripe activan cada una de las ramas. Usa cualquier CVC, cualquier código postal y cualquier fecha de caducidad futura:

TarjetaComportamiento
4000000000003220Siempre requiere 3DS2
4000008400001629Requiere 3DS y después se rechaza con card_declined
4000000000003055Admite 3DS, pero no lo requiere
4242424242424242Admite 3DS, pero la tarjeta no está inscrita, por lo que no se muestra ninguna solicitud

Con las claves de prueba, Stripe muestra una página bancaria simulada con botones para superar o no la verificación. Realiza las pruebas desde tu propio frontend, ya que los pagos creados en el Dashboard de Stripe omiten la redirección de 3DS. Con cada tarjeta, recarga la ruta de retorno una vez cargada. Debe seguir mostrando la pantalla correcta.

Conclusión

En una SPA, el paso a 3D Secure es una navegación, y la ruta de retorno es un arranque en frío. Persiste una referencia al carrito antes de confirmar, reconstruye el estado a partir del servidor cuando el cliente regrese y trata la redirección como una solicitud para consultar el estado, nunca como prueba de pago. Como siguiente paso, añade una ruta /checkout/return, prueba en ella las cuatro tarjetas de prueba y comprueba que ninguna muestre un botón de pago para un intent ya completado.

Preguntas frecuentes

¿Cuál es la diferencia entre el flujo sin fricción (frictionless) y el flujo con verificación (challenge) en 3D Secure 2?

En el flujo sin fricción, el emisor autentica el pago en segundo plano y el cliente no ve ningún paso adicional. En el flujo con verificación, el cliente debe intervenir, por ejemplo, introduciendo un código de un solo uso. La guía de SCA de Stripe indica que una verificación 3DS sin fricción superada también traslada al emisor la responsabilidad por fraude. Si, en cambio, se aplica una exención de SCA, la responsabilidad por las disputas por fraude recae en la empresa.

¿Cómo puedo forzar 3D Secure en un pago de Stripe?

Establece payment_method_options[card][request_three_d_secure] en 'any' o 'challenge' al crear o confirmar el PaymentIntent. Normalmente, Stripe utiliza Radar para decidir, en función del riesgo, cuándo solicitar 3DS. Con este parámetro establecido, Stripe intenta aplicar 3DS a ese pago y tus reglas de Radar de 3DS dinámico dejan de aplicarse a él. El valor 'any' tiende hacia un flujo sin fricción y 'challenge' hacia un flujo con verificación, pero la decisión final corresponde al emisor. Stripe indica que la activación manual está pensada para equipos que utilizan su propio motor antifraude.

¿Qué debe ocurrir si un cliente abandona el checkout durante 3D Secure?

Cuando el cliente regrese, reutiliza el PaymentIntent existente. No crees uno nuevo. Stripe recomienda seguir usando el mismo PaymentIntent cuando se reanuda un checkout interrumpido y actualizar su importe si el carrito ha cambiado. Una autenticación abandonada suele dejar el intent en requires_action. Comprueba primero su estado en el servidor y, si ya figura como succeeded o processing, muestra el estado del pedido en lugar de un formulario de pago.

¿Es seguro que Stripe incluya el client secret en la URL de retorno?

Trata el client secret como información confidencial. La referencia de la API de Stripe advierte que el secret basta para completar el pago desde un navegador, por lo que solo el cliente debe verlo, y nunca debes almacenarlo ni registrarlo en logs. Lee el parámetro payment_intent y, después, elimina la cadena de consulta con history.replaceState. Hazlo pronto, antes de que los scripts de analítica o de reproducción de sesiones lean la URL, para que nunca registren el secret. Sirve las páginas de checkout mediante TLS.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.