Skip to content

3D Secure Takes the Customer Off Your Page

Authentication sends the browser to a bank and back, and everything React was holding is gone. How to keep a payment's state where a redirect cannot destroy it, and what to render meanwhile.

5 min read

A customer clicks pay. Their bank decides this payment needs authentication. The browser leaves your site for a page you have never seen, the customer approves it with an app or a code, and the browser comes back.

At that moment, every piece of React state your checkout was holding is gone. The component tree unmounted, the hooks reset, the variable holding which order this was does not exist. This is not a bug and there is nothing to fix in your code - a redirect is a navigation, and navigations destroy client state.

The checkout has to be built so that losing it costs nothing.

What has to exist before the customer leaves

Before the browser goes anywhere, the server must already hold everything needed to make sense of the return. In practice that means an order row, in a pending state, with an identifier of your own.

That identifier goes into two places. It goes into the payment's metadata at the provider, so an event arriving later can find the order without a lookup table. And it goes into the return_url as a path segment:

return_url: `https://example.com/orders/${order.id}/confirm`

A path rather than a query string, because a path is a page you control and a query string is a parameter the customer can change. That URL is going to be visited by a browser after a round trip through a third party, and everything it carries should be a reference to something the server can verify rather than a claim it has to trust.

Nothing about the amount, the status or the customer goes in that URL. It names an order; the server decides what that order means.

The return page reads the database

The confirmation route is a Server Component, and it loads the order by the id in the path, checks it belongs to the person asking, and renders what the row says.

export default async function Confirm({ params }) {
  const { id } = await params
  const session = await auth()
  const order = await db.order.findUnique({ where: { id } })
 
  if (!order || order.userId !== session?.user?.id) notFound()
 
  if (order.status === 'paid')   return <Receipt order={order} />
  if (order.status === 'failed') return <Retry order={order} />
  return <Pending order={order} />
}

The ownership check is not optional. Order identifiers end up in browser history, in shared links, in support tickets, and an id alone is not authorisation to view a receipt with someone's address on it.

Notice what the page does not look at: the query parameters the provider appended on the way back. Some of them do carry a status, and they are useful for choosing which message to show first, but they came through a browser and they are not what decides whether the order is paid.

The third state is the one that matters

Most teams build two outcomes, success and failure, and discover the third in production. The third is that the payment is real, the customer is here, and your database does not know the answer yet because the webhook has not landed.

It usually lasts a few seconds. Occasionally it lasts minutes, and that is the case the screen has to be designed for.

Poll the order row for a short while - a client component that re-checks a couple of times over ten or fifteen seconds covers almost every real case. Then stop. A page that spins forever teaches the customer that something broke, and the next thing they do is pay again, which turns a slow webhook into a duplicate charge and a refund.

So the pending screen, after the poll gives up, should say plainly that the payment was received and the confirmation is being finalised, show the order reference, and promise an email. That copy is doing real work: it is the difference between a customer who waits and a customer who pays twice.

The state that lives in the URL is the only state that survives

There is a broader principle here that applies well beyond payments, and this is the sharpest example of it.

Anything the application needs after a navigation it does not control has to live somewhere the navigation cannot touch: the URL path, a cookie, or the database. React state does not qualify. sessionStorage does not qualify, because the customer may return in a different tab, on a different device, from an email link. A component holding the answer does not qualify, because it will not be mounted.

In practice this pushes checkout state onto the server, which is where App Router wants it anyway. The caching question then has one firm answer: these pages are never cached. An order confirmation is per customer and changes under you; reading cookies() in the route makes it dynamic, and here that is the behaviour you want rather than a cost.

The rest of the checkout - who computes the price, who may create the order, where the webhook lands - is the same division of trust, and the redirect is simply the moment it gets tested.

Our commerce engagements treat this screen as part of the payment work rather than part of the design work, because it is the last thing a customer sees before deciding whether the money went through.

Related questions

Does this happen on every payment?
No, and that is exactly why it gets missed. Authentication is required by the customer's bank based on the amount, the card, the country and its own risk assessment, so a developer testing with one card in one country can build the whole checkout without ever seeing the redirect. It then appears in production on a meaningful share of European payments.
Can we keep the cart in localStorage and restore it after the redirect?
You can, and for the cart contents it is a reasonable convenience. You cannot use it for anything that decides the outcome, because it lives in one browser and is absent when the same customer opens the confirmation link on their laptop, and because anything the customer can edit is not evidence of what they paid.
What should the return page do if the payment is still processing?
Tell the truth and give it somewhere to go. A short poll on the order row handles the common case where the webhook lands within a few seconds, and after that the page should stop spinning and say the confirmation is on its way with the order reference and an email. An indefinite spinner is how a customer decides the payment failed and pays again.
Is this specific to Stripe?
Not at all. Any redirect-based method has the same shape - iDEAL, Bancontact, most bank transfer methods, local schemes across the Gulf and Turkey, and 3D Secure on cards anywhere. If the flow sends the browser somewhere and expects it back, everything here applies.

Back to all articles