How to Handle 3D Secure in a Single-Page App
Handle 3D Secure in a single-page app with Stripe: preserve checkout state, rebuild the return route, verify PaymentIntent status server-side, and test each flow.
To handle 3D Secure in a single-page app, save the cart and PaymentIntent ID before the bank hand-off, send the customer back to a dedicated return route, rebuild checkout state from your server on that route, and confirm the payment outcome on the server before you fulfil anything.
The bug usually shows up like this: a customer taps “Pay”, goes off to their bank, comes back, and sees an empty cart or a spinner that never stops. Some customers then pay a second time. This article covers the return URL rules on Stripe’s Payment Intents API, the iframe option, server-side confirmation, and the test cards you need to exercise each path.
Key Takeaways
- A full-page redirect to the bank unloads your SPA, so any cart or checkout state held only in memory is gone when the customer returns.
- Stripe adds
payment_intentandpayment_intent_client_secretto yourreturn_url. They tell you which PaymentIntent this is, not whether the payment succeeded. - The return route should read the ID with
URLSearchParams, ask your server for the status, and never show a pay button for an intent that has already succeeded. - A 3D Secure iframe must not have a
sandboxattribute, and your CSP must allow frames fromhttps://js.stripe.com,https://hooks.stripe.comand yourreturn_urlorigin. - Only fulfil an order after a server-side PaymentIntent retrieve or a
payment_intent.succeededwebhook.
What Is 3D Secure?
3D Secure (3DS) is a check the card issuer runs during an online payment to confirm that the cardholder is the one paying. Sometimes it happens in the background. Other times the customer has to act, for example by entering a one-time passcode sent to their phone. Stripe’s Strong Customer Authentication guide explains that SCA is a UK and European rule for payments the customer starts, and Stripe’s SCA readiness page names 3D Secure as the way card payments meet it. In practice, that makes 3DS required for most online card payments where both the business and the card issuer are in the EEA or UK.
Wallets are the exception. Stripe’s 3DS authentication guide lists wallets and off-session payments as examples of transactions that don’t support 3DS. That is one reason a browser-native wallet flow such as the Payment Request API usually skips the challenge step.
Why Do SPAs Lose State During 3D Secure?
A single-page app loses state during 3D Secure because checkout leaves the page. Either the customer goes to their bank’s site, or Stripe sends them to your return_url when confirmation finishes. When they come back, your app has to carry on from where it stopped. The router state, the store and the component tree are all gone. What comes back is a cold boot at your return_url.
Success leaves the page too. By default, stripe.confirmPayment sends the customer to your return_url as soon as confirmation finishes, so its Promise never settles on that page. The code after your await never runs.
Persist State and Set a Dedicated return_url
When a Stripe 3D Secure redirect finishes, the customer lands on your return_url with two query parameters, payment_intent and payment_intent_client_secret. They identify the PaymentIntent but say nothing about whether the payment succeeded. The return_url is the page Stripe sends the customer back to. Point it at a route that exists only for this purpose.
With the Payment Element, stripe.confirmPayment either shows 3DS in a dialog or sends the customer to their bank. By default it then does a full-page redirect to your return_url once confirmation completes. To skip that redirect for cards, pass redirect: 'if_required'. Redirect-based payment methods still leave the page, and you then have to handle the successful result in 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();
}
}
The code only gets past the await if confirmation fails right away. In that case the customer is still on the page, so show the error and re-enable the form. Data in sessionStorage survives navigations and reloads within the same tab. Use it as a pointer to the cart, and keep the cart itself on the server.
Build the Return Route From the Server
The return route reads the PaymentIntent ID from the query string, removes the client secret from the address bar, and asks your backend what happened. Nothing it renders comes from memory. Parse the query with URLSearchParams. Don’t take the substring after the first =, because that breaks as soon as the URL carries a second parameter.
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;
}
If the query string is gone, for example after a reload, the route falls back to the pointer saved in sessionStorage. history.replaceState removes the secret from the address bar and the current history entry. Run it early, before analytics or replay scripts read the URL, so they never record it. Map each status to a screen:
| Status | Return screen |
|---|---|
succeeded | Order confirmation, clear the pending key |
requires_capture | Confirmation (if you authorise and capture separately) |
processing | ”Confirming payment”, then poll your server again |
requires_payment_method | Payment failed, rebuild the cart and ask for another card |
requires_action | The customer may still be authenticating or may have left, so offer to resume |
canceled | Payment cancelled, start a new checkout |
Check the existing intent’s status before you offer to pay again. A return route that rebuilds from memory can show a fresh “Pay” button for an intent that has already succeeded. The failure happens across a page unload, so error logs rarely link the checkout, the bank visit and the return. Session replay of the return visit shows what the customer actually saw: an empty cart, a spinner that never resolves, or a second pay button.
Should You Use a Redirect or an Iframe for 3D Secure?
A full-page redirect is the default and the simplest option. The iframe path keeps the SPA loaded, but you have to build more yourself, and it only works for card payments. In the iframe path, you confirm with automatic action handling turned off, read next_action (the step Stripe says the customer must complete), and load next_action.redirect_to_url.url in a frame.
| Redirect | Iframe | |
|---|---|---|
| Where auth happens | Bank page, top level | Bank page inside your modal |
| SPA unloads | Yes | No |
| You build | Return route | Frame, postMessage page, listener |
| CSP impact | None for the frame | frame-src entries |
| Fallback | Not needed | Redirect |
The snippet below turns the card details from the Payment Element into a PaymentMethod, then confirms with Stripe’s own 3DS handling switched off. For this to work, create the Elements instance with paymentMethodCreation: 'manual'. Stripe’s Elements reference says this option is what lets stripe.createPaymentMethod build a PaymentMethod from the Payment Element. You also have to call elements.submit() first, which checks the form.
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);
}
The /checkout/3ds-done page runs window.top?.postMessage('3ds-complete', window.location.origin). The parent page checks event.origin against its own origin before it acts, as MDN’s postMessage guidance recommends. Then it removes the frame and asks your server for the status.
Don’t put a sandbox attribute on the 3D Secure iframe. The card issuer controls part of what loads in that frame, and Stripe’s 3DS guide notes that some issuer pages stop working when sandboxed, so the payment fails. If you send a Content Security Policy, its frame-src directive must allow https://js.stripe.com, https://hooks.stripe.com and your return_url origin. Also give customers a way out: an “Open bank page” link that calls window.location.assign(url). In that case the bank sends them to /checkout/3ds-done as a full page, so have that page check whether window.top === window and, if so, forward to /checkout/return with the same query string. The customer then goes through the same return route as the redirect flow.
Why Must You Confirm the Outcome Server-Side?
Landing on your return_url after 3D Secure tells you nothing about the payment outcome. Anyone can type a query string into that URL. Fulfilment should depend only on a server-side retrieve or a 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 });
});
Before you respond, check that the intent belongs to the current session’s cart. Stripe’s guide to payment status updates says to drive fulfilment from webhooks rather than polling. Listen for payment_intent.succeeded to fulfil the order and payment_intent.payment_failed to tell the customer. The OpenReplay webhooks guide covers the endpoint side. A successful 3DS check usually moves the cost of eligible fraud disputes to the card issuer, but Stripe is clear that this is never guaranteed.
How Do You Test the 3D Secure Flow?
Stripe’s test cards trigger each branch. Use any CVC, any postal code and any future expiry date:
| Card | Behaviour |
|---|---|
4000000000003220 | Always requires 3DS2 |
4000008400001629 | 3DS required, then declined with card_declined |
4000000000003055 | 3DS supported but not required |
4242424242424242 | 3DS supported, but the card isn’t enrolled, so no prompt |
With test keys, Stripe shows a fake bank page with buttons to pass or fail the check. Test through your own frontend. Payments you create in the Stripe Dashboard skip the 3DS redirect. For each card, reload the return route once it has loaded. It should still show the right screen.
Conclusion
In an SPA, the 3D Secure hand-off is a navigation, and the return route is a cold start. Persist a pointer to the cart before you confirm, rebuild state from the server when the customer comes back, and treat the redirect as a request to go and check the status, never as proof of payment. Next, add a /checkout/return route, run all four test cards through it, and check that none of them shows a pay button for a succeeded intent.
FAQs
What is the difference between the frictionless and challenge flows in 3D Secure 2?
In the frictionless flow, the issuer authenticates the payment in the background and the customer sees no extra step. In the challenge flow, the customer must act, for example by entering a one-time passcode. Stripe's SCA guide says a successful frictionless 3DS check still moves fraud liability to the issuer. When an SCA exemption is applied instead, the liability for fraud disputes stays with the business.
How do I force 3D Secure on a Stripe payment?
Set payment_method_options[card][request_three_d_secure] to 'any' or 'challenge' when you create or confirm the PaymentIntent. Normally Stripe uses Radar to decide when to ask for 3DS based on risk. With the parameter set, Stripe tries 3DS on that payment and your dynamic 3DS Radar rules no longer apply to it. The value 'any' leans toward a frictionless flow and 'challenge' toward a challenge, but the issuer makes the final call. Stripe says manual triggering is meant for teams running their own fraud engine.
What should happen if a customer abandons checkout during 3D Secure?
When the customer comes back, reuse the existing PaymentIntent. Don't create a new one. Stripe's advice is to keep using the same PaymentIntent when an interrupted checkout picks up again, and to update its amount if the cart changed. An abandoned authentication usually leaves the intent in requires_action. Check its status on your server first, and if it already shows succeeded or processing, show the order state instead of a payment form.
Is it safe that Stripe puts the client secret in the return URL?
Treat the client secret as sensitive. Stripe's API reference warns that the secret is enough to finish the payment from a browser, so only the customer should ever see it, and you should never store or log it. Read the payment_intent parameter, then strip the query string with history.replaceState. Do this early, before analytics or session replay scripts read the URL, so they never record the secret. Serve checkout pages over TLS.