3D Secure in einer Single-Page-App richtig umsetzen
Behandeln Sie 3D Secure in einer Single-Page-App mit Stripe: Checkout-Status sichern, Rückkehrroute neu laden und den PaymentIntent serverseitig prüfen.
Um 3D Secure in einer Single-Page-App (SPA) korrekt umzusetzen, speichern Sie vor der Übergabe an die Bank den Warenkorb und die PaymentIntent-ID, leiten den Kunden anschließend auf eine eigens dafür vorgesehene Return-Route zurück, bauen dort den Checkout-Zustand anhand Ihres Servers neu auf und bestätigen das Zahlungsergebnis serverseitig, bevor Sie eine Bestellung abwickeln.
Der Fehler zeigt sich meist so: Ein Kunde tippt auf „Bezahlen“, wird zu seiner Bank weitergeleitet, kehrt zurück und sieht einen leeren Warenkorb oder einen Ladeindikator, der sich endlos dreht. Manche Kunden bezahlen daraufhin ein zweites Mal. Dieser Artikel behandelt die Regeln für die Return-URL in der Payment Intents API von Stripe, die Iframe-Variante, die serverseitige Bestätigung sowie die Testkarten, mit denen Sie jeden Pfad durchspielen können.
Das Wichtigste in Kürze
- Eine ganzseitige Weiterleitung zur Bank entlädt Ihre SPA. Warenkorb- oder Checkout-Daten, die nur im Arbeitsspeicher liegen, sind bei der Rückkehr des Kunden verloren.
- Stripe hängt
payment_intentundpayment_intent_client_secretan Ihrereturn_urlan. Diese Parameter verraten Ihnen, um welchen PaymentIntent es sich handelt, nicht aber, ob die Zahlung erfolgreich war. - Die Return-Route sollte die ID mit
URLSearchParamsauslesen, den Status bei Ihrem Server abfragen und niemals einen Bezahl-Button für einen bereits erfolgreichen Intent anzeigen. - Ein 3D-Secure-Iframe darf kein
sandbox-Attribut besitzen, und Ihre CSP muss Frames vonhttps://js.stripe.com,https://hooks.stripe.comund dem Origin Ihrerreturn_urlzulassen. - Wickeln Sie eine Bestellung erst nach einem serverseitigen Abruf des PaymentIntent oder nach einem
payment_intent.succeeded-Webhook ab.
Was ist 3D Secure?
3D Secure (3DS) ist eine Prüfung, die der Kartenaussteller bei einer Onlinezahlung durchführt, um sicherzustellen, dass tatsächlich der Karteninhaber bezahlt. Manchmal läuft sie im Hintergrund ab. In anderen Fällen muss der Kunde selbst aktiv werden, etwa indem er einen Einmalcode eingibt, der an sein Smartphone gesendet wurde. Der Leitfaden zur starken Kundenauthentifizierung von Stripe erläutert, dass die Strong Customer Authentication (SCA) eine Vorgabe im Vereinigten Königreich und in Europa für vom Kunden initiierte Zahlungen ist, und auf der SCA-Readiness-Seite von Stripe wird 3D Secure als das Verfahren genannt, mit dem Kartenzahlungen diese Anforderung erfüllen. In der Praxis ist 3DS damit für die meisten Online-Kartenzahlungen Pflicht, bei denen sowohl das Unternehmen als auch der Kartenaussteller im EWR oder im Vereinigten Königreich ansässig sind.
Wallets bilden die Ausnahme. Der 3DS-Authentifizierungsleitfaden von Stripe führt Wallets und Off-Session-Zahlungen als Beispiele für Transaktionen auf, die 3DS nicht unterstützen. Das ist ein Grund, warum ein browsernativer Wallet-Flow wie die Payment Request API den Challenge-Schritt in der Regel überspringt.
Warum verlieren SPAs bei 3D Secure ihren Zustand?
Eine Single-Page-App verliert bei 3D Secure ihren Zustand, weil der Checkout die Seite verlässt. Entweder wechselt der Kunde auf die Website seiner Bank, oder Stripe leitet ihn nach Abschluss der Bestätigung auf Ihre return_url weiter. Bei der Rückkehr muss Ihre App dort weitermachen, wo sie aufgehört hat. Router-Zustand, Store und Komponentenbaum sind jedoch verschwunden. Was zurückkommt, ist ein Kaltstart auf Ihrer return_url.
Auch eine erfolgreiche Zahlung verlässt die Seite. Standardmäßig leitet stripe.confirmPayment den Kunden unmittelbar nach Abschluss der Bestätigung auf Ihre return_url weiter, sodass das zugehörige Promise auf dieser Seite nie aufgelöst wird. Der Code nach Ihrem await wird nie ausgeführt.
Zustand persistieren und eine dedizierte return_url festlegen
Wenn eine 3D-Secure-Weiterleitung über Stripe abgeschlossen ist, landet der Kunde auf Ihrer return_url mit den beiden Query-Parametern payment_intent und payment_intent_client_secret. Sie identifizieren den PaymentIntent, sagen aber nichts darüber aus, ob die Zahlung erfolgreich war. Die return_url ist die Seite, auf die Stripe den Kunden zurückleitet. Lassen Sie sie auf eine Route zeigen, die ausschließlich diesem Zweck dient.
Mit dem Payment Element zeigt stripe.confirmPayment 3DS entweder in einem Dialog an oder leitet den Kunden zu seiner Bank weiter. Anschließend erfolgt standardmäßig eine ganzseitige Weiterleitung auf Ihre return_url, sobald die Bestätigung abgeschlossen ist. Um diese Weiterleitung bei Kartenzahlungen zu vermeiden, übergeben Sie redirect: 'if_required'. Weiterleitungsbasierte Zahlungsmethoden verlassen die Seite dennoch, und Sie müssen das erfolgreiche Ergebnis dann im Code behandeln.
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();
}
}
Der Code gelangt nur dann über das await hinaus, wenn die Bestätigung sofort fehlschlägt. In diesem Fall befindet sich der Kunde noch auf der Seite, also zeigen Sie den Fehler an und aktivieren das Formular wieder. Daten im sessionStorage überstehen Navigationen und Neuladevorgänge innerhalb desselben Tabs. Nutzen Sie ihn als Verweis auf den Warenkorb und halten Sie den Warenkorb selbst auf dem Server.
Die Return-Route anhand des Servers aufbauen
Die Return-Route liest die PaymentIntent-ID aus dem Query-String, entfernt das Client Secret aus der Adressleiste und fragt Ihr Backend, was passiert ist. Nichts von dem, was sie rendert, stammt aus dem Arbeitsspeicher. Parsen Sie den Query-String mit URLSearchParams. Verwenden Sie nicht einfach den Teilstring nach dem ersten =, denn das funktioniert nicht mehr, sobald die URL einen zweiten Parameter enthält.
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;
}
Fehlt der Query-String, etwa nach einem Neuladen, greift die Route auf den im sessionStorage gespeicherten Verweis zurück. history.replaceState entfernt das Secret aus der Adressleiste und dem aktuellen Verlaufseintrag. Führen Sie den Aufruf frühzeitig aus, bevor Analytics- oder Replay-Skripte die URL auslesen, damit diese das Secret nie erfassen. Ordnen Sie jedem Status einen Bildschirm zu:
| Status | Bildschirm nach der Rückkehr |
|---|---|
succeeded | Bestellbestätigung, Pending-Key löschen |
requires_capture | Bestätigung (wenn Sie Autorisierung und Erfassung getrennt durchführen) |
processing | „Zahlung wird bestätigt“, anschließend erneut beim Server abfragen |
requires_payment_method | Zahlung fehlgeschlagen, Warenkorb neu aufbauen und nach einer anderen Karte fragen |
requires_action | Der Kunde authentifiziert sich möglicherweise noch oder hat abgebrochen, daher Fortsetzen anbieten |
canceled | Zahlung storniert, neuen Checkout starten |
Prüfen Sie den Status des bestehenden Intents, bevor Sie eine erneute Zahlung anbieten. Eine Return-Route, die sich aus dem Arbeitsspeicher wiederaufbaut, kann einen neuen „Bezahlen“-Button für einen Intent anzeigen, der bereits erfolgreich war. Da der Fehler über ein Entladen der Seite hinweg auftritt, verknüpfen Fehlerprotokolle Checkout, Bankbesuch und Rückkehr nur selten miteinander. Ein Session Replay des Rückkehrbesuchs zeigt, was der Kunde tatsächlich gesehen hat: einen leeren Warenkorb, einen Ladeindikator, der nie endet, oder einen zweiten Bezahl-Button.
Weiterleitung oder Iframe für 3D Secure?
Eine ganzseitige Weiterleitung ist der Standard und die einfachste Variante. Beim Iframe-Ansatz bleibt die SPA geladen, Sie müssen jedoch mehr selbst implementieren, und er funktioniert nur für Kartenzahlungen. Dabei führen Sie die Bestätigung mit deaktivierter automatischer Aktionsbehandlung durch, lesen next_action aus (den Schritt, den der Kunde laut Stripe abschließen muss) und laden next_action.redirect_to_url.url in einem Frame.
| Weiterleitung | Iframe | |
|---|---|---|
| Ort der Authentifizierung | Bankseite, oberste Ebene | Bankseite in Ihrem Modal |
| SPA wird entladen | Ja | Nein |
| Von Ihnen zu implementieren | Return-Route | Frame, postMessage-Seite, Listener |
| Auswirkung auf die CSP | Keine für den Frame | frame-src-Einträge |
| Fallback | Nicht erforderlich | Weiterleitung |
Das folgende Snippet wandelt die Kartendaten aus dem Payment Element in eine PaymentMethod um und führt die Bestätigung anschließend mit deaktivierter 3DS-Behandlung durch Stripe aus. Damit das funktioniert, erstellen Sie die Elements-Instanz mit paymentMethodCreation: 'manual'. Laut der Elements-Referenz von Stripe ermöglicht genau diese Option, dass stripe.createPaymentMethod eine PaymentMethod aus dem Payment Element erzeugt. Außerdem müssen Sie zuvor elements.submit() aufrufen, das das Formular validiert.
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);
}
Die Seite /checkout/3ds-done führt window.top?.postMessage('3ds-complete', window.location.origin) aus. Die übergeordnete Seite gleicht event.origin mit ihrem eigenen Origin ab, bevor sie reagiert, wie es die postMessage-Empfehlungen von MDN vorsehen. Anschließend entfernt sie den Frame und fragt den Status bei Ihrem Server ab.
Versehen Sie das 3D-Secure-Iframe nicht mit einem sandbox-Attribut. Der Kartenaussteller kontrolliert einen Teil dessen, was in diesem Frame geladen wird, und laut dem 3DS-Leitfaden von Stripe funktionieren manche Seiten von Kartenausstellern in einer Sandbox nicht, sodass die Zahlung fehlschlägt. Wenn Sie eine Content Security Policy ausliefern, muss deren frame-src-Direktive https://js.stripe.com, https://hooks.stripe.com und den Origin Ihrer return_url zulassen. Bieten Sie Kunden zudem einen Ausweg an: einen Link „Bankseite öffnen“, der window.location.assign(url) aufruft. In diesem Fall leitet die Bank den Kunden als ganze Seite auf /checkout/3ds-done weiter. Lassen Sie diese Seite daher prüfen, ob window.top === window gilt, und gegebenenfalls mit demselben Query-String auf /checkout/return weiterleiten. Der Kunde durchläuft dann dieselbe Return-Route wie beim Weiterleitungs-Flow.
Warum muss das Ergebnis serverseitig bestätigt werden?
Dass ein Kunde nach 3D Secure auf Ihrer return_url landet, sagt nichts über das Zahlungsergebnis aus. Jeder kann einen Query-String in diese URL eintippen. Die Abwicklung einer Bestellung sollte ausschließlich von einem serverseitigen Abruf oder einem Webhook abhängen.
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 });
});
Prüfen Sie vor dem Antworten, ob der Intent zum Warenkorb der aktuellen Session gehört. Der Leitfaden zu Zahlungsstatus-Updates von Stripe empfiehlt, die Abwicklung über Webhooks statt über Polling zu steuern. Hören Sie auf payment_intent.succeeded, um die Bestellung abzuwickeln, und auf payment_intent.payment_failed, um den Kunden zu informieren. Der Webhook-Leitfaden von OpenReplay behandelt die Endpunktseite. Eine erfolgreiche 3DS-Prüfung verlagert die Haftung für berechtigte Betrugsanfechtungen in der Regel auf den Kartenaussteller, Stripe stellt jedoch klar, dass dies nie garantiert ist.
Wie testen Sie den 3D-Secure-Flow?
Die Testkarten von Stripe lösen jeden Zweig aus. Verwenden Sie eine beliebige CVC, eine beliebige Postleitzahl und ein beliebiges Ablaufdatum in der Zukunft:
| Karte | Verhalten |
|---|---|
4000000000003220 | Erfordert immer 3DS2 |
4000008400001629 | 3DS erforderlich, anschließend Ablehnung mit card_declined |
4000000000003055 | 3DS unterstützt, aber nicht erforderlich |
4242424242424242 | 3DS unterstützt, die Karte ist jedoch nicht registriert, daher keine Abfrage |
Mit Test-Keys zeigt Stripe eine simulierte Bankseite mit Buttons an, über die Sie die Prüfung bestehen oder scheitern lassen können. Testen Sie über Ihr eigenes Frontend. Im Stripe Dashboard erstellte Zahlungen überspringen die 3DS-Weiterleitung. Laden Sie für jede Karte die Return-Route nach dem ersten Laden noch einmal neu. Sie sollte weiterhin den richtigen Bildschirm anzeigen.
Fazit
In einer SPA ist die Übergabe an 3D Secure eine Navigation, und die Return-Route ist ein Kaltstart. Persistieren Sie vor der Bestätigung einen Verweis auf den Warenkorb, bauen Sie den Zustand bei der Rückkehr des Kunden anhand des Servers neu auf und betrachten Sie die Weiterleitung als Aufforderung, den Status zu prüfen, niemals als Zahlungsnachweis. Fügen Sie als Nächstes eine /checkout/return-Route hinzu, spielen Sie alle vier Testkarten damit durch und stellen Sie sicher, dass keine davon einen Bezahl-Button für einen erfolgreichen Intent anzeigt.
FAQs
Was ist der Unterschied zwischen Frictionless Flow und Challenge Flow bei 3D Secure 2?
Im Frictionless Flow authentifiziert der Kartenaussteller die Zahlung im Hintergrund, und der Kunde sieht keinen zusätzlichen Schritt. Im Challenge Flow muss der Kunde selbst aktiv werden, etwa durch Eingabe eines Einmalcodes. Laut dem SCA-Leitfaden von Stripe verlagert auch eine erfolgreiche reibungslose 3DS-Prüfung die Betrugshaftung auf den Kartenaussteller. Wird stattdessen eine SCA-Ausnahme angewendet, verbleibt die Haftung für Betrugsanfechtungen beim Unternehmen.
Wie erzwinge ich 3D Secure bei einer Stripe-Zahlung?
Setzen Sie payment_method_options[card][request_three_d_secure] beim Erstellen oder Bestätigen des PaymentIntent auf 'any' oder 'challenge'. Normalerweise entscheidet Stripe mithilfe von Radar risikobasiert, wann 3DS angefordert wird. Ist der Parameter gesetzt, versucht Stripe bei dieser Zahlung 3DS durchzuführen, und Ihre dynamischen 3DS-Regeln in Radar gelten für sie nicht mehr. Der Wert 'any' tendiert zu einem Frictionless Flow, 'challenge' zu einer Challenge, die endgültige Entscheidung trifft jedoch der Kartenaussteller. Laut Stripe ist das manuelle Auslösen für Teams gedacht, die eine eigene Betrugserkennung betreiben.
Was sollte passieren, wenn ein Kunde den Checkout während 3D Secure abbricht?
Verwenden Sie bei der Rückkehr des Kunden den bestehenden PaymentIntent weiter und erstellen Sie keinen neuen. Stripe empfiehlt, bei der Fortsetzung eines unterbrochenen Checkouts denselben PaymentIntent weiterzuverwenden und dessen Betrag zu aktualisieren, falls sich der Warenkorb geändert hat. Eine abgebrochene Authentifizierung hinterlässt den Intent in der Regel im Status requires_action. Prüfen Sie seinen Status zunächst auf Ihrem Server, und wenn er bereits succeeded oder processing lautet, zeigen Sie den Bestellstatus statt eines Zahlungsformulars an.
Ist es sicher, dass Stripe das Client Secret in die Return-URL schreibt?
Behandeln Sie das Client Secret als sensible Information. Die API-Referenz von Stripe warnt, dass das Secret ausreicht, um die Zahlung aus einem Browser heraus abzuschließen. Daher sollte es ausschließlich der Kunde zu sehen bekommen, und Sie sollten es niemals speichern oder protokollieren. Lesen Sie den Parameter payment_intent aus und entfernen Sie anschließend den Query-String mit history.replaceState. Tun Sie dies frühzeitig, bevor Analytics- oder Session-Replay-Skripte die URL auslesen, damit diese das Secret nie erfassen. Liefern Sie Checkout-Seiten über TLS aus.