Comment gérer 3D Secure dans une application monopage
Gérez 3D Secure dans une application monopage avec Stripe : préservez l’état du paiement, reconstruisez la page de retour et vérifiez le PaymentIntent côté serveur.
Pour gérer 3D Secure dans une application monopage (SPA), enregistrez le panier et l’identifiant du PaymentIntent avant la redirection vers la banque, et renvoyez le client vers une route de retour dédiée. Sur cette route, reconstruisez l’état du tunnel de paiement à partir de votre serveur, puis confirmez le résultat du paiement côté serveur avant d’exécuter la commande.
Le bug se manifeste généralement ainsi : un client appuie sur « Payer », passe par le site de sa banque, revient, puis tombe sur un panier vide ou sur un indicateur de chargement qui tourne indéfiniment. Certains clients paient alors une seconde fois. Cet article couvre les règles relatives à l’URL de retour dans l’API Payment Intents de Stripe, l’option iframe, la confirmation côté serveur et les cartes de test nécessaires pour vérifier chaque scénario.
Points clés à retenir
- Une redirection pleine page vers la banque décharge votre SPA : tout état du panier ou du tunnel de paiement conservé uniquement en mémoire est perdu au retour du client.
- Stripe ajoute
payment_intentetpayment_intent_client_secretà votrereturn_url. Ces paramètres indiquent de quel PaymentIntent il s’agit, et non si le paiement a abouti. - La route de retour doit lire l’identifiant avec
URLSearchParams, demander le statut à votre serveur et ne jamais afficher de bouton de paiement pour un intent déjà réussi. - Une iframe 3D Secure ne doit pas comporter d’attribut
sandbox, et votre CSP doit autoriser les frames provenant dehttps://js.stripe.com, dehttps://hooks.stripe.comet de l’origine de votrereturn_url. - N’exécutez une commande qu’après une récupération (retrieve) du PaymentIntent côté serveur ou la réception d’un webhook
payment_intent.succeeded.
Qu’est-ce que 3D Secure ?
3D Secure (3DS) est une vérification effectuée par l’émetteur de la carte lors d’un paiement en ligne afin de confirmer que c’est bien le titulaire de la carte qui paie. Elle se déroule parfois en arrière-plan. Dans d’autres cas, le client doit agir, par exemple en saisissant un code à usage unique envoyé sur son téléphone. Le guide de Stripe sur l’authentification forte du client explique que la SCA est une réglementation britannique et européenne qui s’applique aux paiements initiés par le client. La page de Stripe consacrée à la conformité SCA désigne quant à elle 3D Secure comme le moyen de mise en conformité des paiements par carte. En pratique, 3DS est donc obligatoire pour la plupart des paiements par carte en ligne lorsque l’entreprise et l’émetteur de la carte sont tous deux situés dans l’EEE ou au Royaume-Uni.
Les wallets (portefeuilles électroniques) font exception. Le guide d’authentification 3DS de Stripe cite les wallets et les paiements hors session parmi les transactions qui ne prennent pas en charge 3DS. C’est l’une des raisons pour lesquelles un flux de wallet natif au navigateur, comme celui de la Payment Request API, ignore généralement l’étape de challenge.
Pourquoi les SPA perdent-elles leur état pendant 3D Secure ?
Une application monopage perd son état pendant 3D Secure parce que le tunnel de paiement quitte la page. Soit le client est redirigé vers le site de sa banque, soit Stripe le renvoie vers votre return_url une fois la confirmation terminée. À son retour, votre application doit reprendre là où elle s’était arrêtée. Or l’état du routeur, le store et l’arbre de composants ont tous disparu. Ce qui revient, c’est un démarrage à froid sur votre return_url.
Un paiement réussi quitte lui aussi la page. Par défaut, stripe.confirmPayment renvoie le client vers votre return_url dès la fin de la confirmation : sa Promise n’est donc jamais résolue sur cette page, et le code situé après votre await ne s’exécute jamais.
Persister l’état et définir une return_url dédiée
À la fin d’une redirection 3D Secure de Stripe, le client arrive sur votre return_url avec deux paramètres de requête, payment_intent et payment_intent_client_secret. Ils identifient le PaymentIntent, mais n’indiquent en rien si le paiement a abouti. La return_url est la page vers laquelle Stripe renvoie le client. Faites-la pointer vers une route qui n’existe que pour cet usage.
Avec le Payment Element, stripe.confirmPayment affiche le challenge 3DS dans une boîte de dialogue ou redirige le client vers sa banque. Par défaut, il effectue ensuite une redirection pleine page vers votre return_url une fois la confirmation terminée. Pour éviter cette redirection dans le cas des cartes, passez redirect: 'if_required'. Les moyens de paiement fondés sur une redirection quittent tout de même la page, et vous devez alors gérer le résultat positif dans votre code.
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();
}
}
Le code ne dépasse l’await que si la confirmation échoue immédiatement. Dans ce cas, le client est toujours sur la page : affichez l’erreur et réactivez le formulaire. Les données stockées dans sessionStorage survivent aux navigations et aux rechargements au sein d’un même onglet. Utilisez-le pour conserver une référence vers le panier, et gardez le panier lui-même sur le serveur.
Construire la route de retour à partir du serveur
La route de retour lit l’identifiant du PaymentIntent dans la chaîne de requête, retire le client secret de la barre d’adresse et demande à votre backend ce qui s’est passé. Rien de ce qu’elle affiche ne provient de la mémoire. Analysez la chaîne de requête avec URLSearchParams. Ne vous contentez pas d’extraire la sous-chaîne située après le premier = : cette approche échoue dès que l’URL contient un second paramètre.
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 chaîne de requête a disparu, par exemple après un rechargement, la route se rabat sur la référence enregistrée dans sessionStorage. history.replaceState retire le secret de la barre d’adresse et de l’entrée d’historique courante. Exécutez-le le plus tôt possible, avant que les scripts d’analytics ou de session replay ne lisent l’URL, afin qu’ils ne l’enregistrent jamais. Associez ensuite chaque statut à un écran :
| Statut | Écran de retour |
|---|---|
succeeded | Confirmation de commande, suppression de la clé en attente |
requires_capture | Confirmation (si vous autorisez et capturez séparément) |
processing | « Confirmation du paiement en cours », puis nouvelle interrogation du serveur |
requires_payment_method | Paiement échoué : reconstruire le panier et demander une autre carte |
requires_action | Le client est peut-être encore en cours d’authentification ou a quitté le parcours : proposer de reprendre |
canceled | Paiement annulé : démarrer un nouveau tunnel de paiement |
Vérifiez le statut de l’intent existant avant de proposer un nouveau paiement. Une route de retour qui se reconstruit à partir de la mémoire peut afficher un nouveau bouton « Payer » pour un intent déjà réussi. Comme la défaillance survient au travers d’un déchargement de page, les journaux d’erreurs relient rarement le tunnel de paiement, le passage par la banque et le retour. La relecture de session (session replay) de la visite de retour montre ce que le client a réellement vu : un panier vide, un indicateur de chargement interminable ou un second bouton de paiement.
Faut-il utiliser une redirection ou une iframe pour 3D Secure ?
La redirection pleine page est l’option par défaut et la plus simple. L’approche par iframe garde la SPA chargée, mais vous oblige à développer davantage de choses vous-même, et elle ne fonctionne que pour les paiements par carte. Avec l’iframe, vous confirmez le paiement en désactivant la gestion automatique des actions, vous lisez next_action (l’étape que le client doit accomplir selon Stripe) et vous chargez next_action.redirect_to_url.url dans une frame.
| Redirection | Iframe | |
|---|---|---|
| Lieu de l’authentification | Page de la banque, au premier niveau | Page de la banque dans votre fenêtre modale |
| Déchargement de la SPA | Oui | Non |
| À développer | Route de retour | Frame, page postMessage, écouteur |
| Impact sur la CSP | Aucun pour la frame | Entrées frame-src |
| Solution de repli | Inutile | Redirection |
L’extrait ci-dessous transforme les coordonnées de carte saisies dans le Payment Element en PaymentMethod, puis confirme le paiement en désactivant la gestion 3DS native de Stripe. Pour que cela fonctionne, créez l’instance Elements avec paymentMethodCreation: 'manual'. Selon la référence Elements de Stripe, c’est cette option qui permet à stripe.createPaymentMethod de construire un PaymentMethod à partir du Payment Element. Vous devez également appeler elements.submit() au préalable afin de valider le formulaire.
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 page /checkout/3ds-done exécute window.top?.postMessage('3ds-complete', window.location.origin). Avant d’agir, la page parente compare event.origin à sa propre origine, comme le recommande la documentation MDN sur postMessage. Elle supprime ensuite la frame et demande le statut à votre serveur.
N’ajoutez pas d’attribut sandbox à l’iframe 3D Secure. L’émetteur de la carte contrôle une partie de ce qui se charge dans cette frame, et le guide 3DS de Stripe signale que certaines pages d’émetteurs cessent de fonctionner lorsqu’elles sont placées en sandbox, ce qui fait échouer le paiement. Si vous envoyez une Content Security Policy, sa directive frame-src doit autoriser https://js.stripe.com, https://hooks.stripe.com et l’origine de votre return_url.
Prévoyez également une porte de sortie pour les clients : un lien « Ouvrir la page de la banque » qui appelle window.location.assign(url). Dans ce cas, la banque renvoie le client vers /checkout/3ds-done en pleine page. Cette page doit donc vérifier si window.top === window et, le cas échéant, rediriger vers /checkout/return en conservant la même chaîne de requête. Le client passe alors par la même route de retour que dans le flux par redirection.
Pourquoi faut-il confirmer le résultat côté serveur ?
Le simple fait d’arriver sur votre return_url après 3D Secure ne vous apprend rien sur le résultat du paiement : n’importe qui peut saisir une chaîne de requête dans cette URL. L’exécution de la commande ne doit dépendre que d’une récupération côté serveur ou d’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 });
});
Avant de répondre, vérifiez que l’intent correspond bien au panier de la session en cours. Le guide de Stripe sur les mises à jour du statut des paiements recommande de piloter l’exécution des commandes par webhooks plutôt que par polling. Écoutez payment_intent.succeeded pour exécuter la commande et payment_intent.payment_failed pour prévenir le client. Le guide OpenReplay sur les webhooks couvre la partie endpoint.
Une vérification 3DS réussie transfère généralement à l’émetteur de la carte le coût des litiges pour fraude éligibles, mais Stripe précise clairement que ce transfert n’est jamais garanti.
Comment tester le flux 3D Secure ?
Les cartes de test de Stripe permettent de déclencher chaque scénario. Utilisez n’importe quel CVC, n’importe quel code postal et n’importe quelle date d’expiration future :
| Carte | Comportement |
|---|---|
4000000000003220 | Exige toujours 3DS2 |
4000008400001629 | 3DS requis, puis refus avec card_declined |
4000000000003055 | 3DS pris en charge mais non requis |
4242424242424242 | 3DS pris en charge, mais la carte n’est pas enrôlée : aucune demande d’authentification |
Avec les clés de test, Stripe affiche une fausse page bancaire comportant des boutons pour valider ou faire échouer la vérification. Testez via votre propre frontend, car les paiements créés depuis le Dashboard Stripe contournent la redirection 3DS. Pour chaque carte, rechargez la route de retour une fois qu’elle s’est affichée : elle doit continuer à présenter le bon écran.
Conclusion
Dans une SPA, la redirection 3D Secure est une navigation, et la route de retour est un démarrage à froid. Enregistrez une référence vers le panier avant la confirmation, reconstruisez l’état à partir du serveur au retour du client, et considérez la redirection comme une invitation à vérifier le statut, jamais comme une preuve de paiement. Prochaine étape : ajoutez une route /checkout/return, faites-y passer les quatre cartes de test et vérifiez qu’aucune d’entre elles n’affiche de bouton de paiement pour un intent réussi.
FAQ
Quelle est la différence entre le flux sans friction et le flux challenge dans 3D Secure 2 ?
Dans le flux sans friction (frictionless), l'émetteur authentifie le paiement en arrière-plan et le client ne voit aucune étape supplémentaire. Dans le flux challenge, le client doit agir, par exemple en saisissant un code à usage unique. Selon le guide SCA de Stripe, une vérification 3DS sans friction réussie transfère tout de même la responsabilité en cas de fraude à l'émetteur. Lorsqu'une exemption SCA est appliquée à la place, la responsabilité des litiges pour fraude reste à la charge de l'entreprise.
Comment forcer 3D Secure sur un paiement Stripe ?
Définissez payment_method_options[card][request_three_d_secure] sur 'any' ou 'challenge' lors de la création ou de la confirmation du PaymentIntent. Normalement, Stripe s'appuie sur Radar pour décider, en fonction du risque, quand demander 3DS. Lorsque ce paramètre est défini, Stripe tente 3DS sur ce paiement, et vos règles Radar de 3DS dynamique ne s'y appliquent plus. La valeur 'any' privilégie un flux sans friction et 'challenge' un flux challenge, mais c'est l'émetteur qui a le dernier mot. Stripe indique que le déclenchement manuel est destiné aux équipes qui utilisent leur propre moteur antifraude.
Que faire si un client abandonne le tunnel de paiement pendant 3D Secure ?
Lorsque le client revient, réutilisez le PaymentIntent existant au lieu d'en créer un nouveau. Stripe recommande de conserver le même PaymentIntent à la reprise d'un tunnel de paiement interrompu, et d'en mettre à jour le montant si le panier a changé. Une authentification abandonnée laisse généralement l'intent dans l'état requires_action. Vérifiez d'abord son statut sur votre serveur : s'il indique déjà succeeded ou processing, affichez l'état de la commande plutôt qu'un formulaire de paiement.
Est-il sûr que Stripe place le client secret dans l'URL de retour ?
Considérez le client secret comme une donnée sensible. La référence de l'API Stripe avertit que ce secret suffit à finaliser le paiement depuis un navigateur : seul le client doit pouvoir le voir, et vous ne devez jamais le stocker ni le journaliser. Lisez le paramètre payment_intent, puis supprimez la chaîne de requête avec history.replaceState. Faites-le le plus tôt possible, avant que les scripts d'analytics ou de session replay ne lisent l'URL, afin qu'ils n'enregistrent jamais le secret. Servez vos pages de paiement via TLS.