WooCommerce Stripe 3D Secure Authentication Loop Fix

WooCommerce Stripe checkout stuck in a 3D Secure authentication loop? Fix the redirect_url, payment_intent confirmation race, and webhook with code.
WooCommerceStripeWordPress
May 3, 20265 min read931 words

The Problem

A client running a B2B WooCommerce store messaged me last Friday: customers in the EU were stuck in a 3D Secure authentication loop. Buyer clicks "Place Order", the Stripe modal pops up, they tap Approve in their banking app, and the modal reloads the same authentication challenge. After three or four retries the order errors out and the cart is wiped. No payment captured, customer angry, and the Stripe Dashboard shows the payment_intent stuck on requires_action.

If your WooCommerce checkout is doing the same thing — endless 3DS modal, payment_intent stuck on requires_action, or customers seeing "We were unable to process your payment" after a successful bank confirmation — you are hitting one of three issues with the WooCommerce Stripe Gateway 9.x release on stores that also run page caching.

Why It Happens

Stripe Payment Intents go through three states during 3DS checkout: requires_payment_method to requires_confirmation to requires_action to succeeded. The WooCommerce Stripe plugin handles the redirect and confirmation, but two recent changes broke the flow on stores I have audited.

First, in WooCommerce Stripe Gateway 9.0, the plugin moved confirmCardPayment entirely client-side after the process_payment filter. If your theme or another plugin has wp_redirect or page-cache logic on /checkout/order-pay/, the redirect_url returned from Stripe gets clobbered. The browser hits your cached checkout page, which re-creates a fresh payment_intent and asks for 3DS again. That is the loop.

Second, in stores with full-page caching (WP Rocket, LiteSpeed, host-level Varnish), the ?key=wc_order_xxx&pay_for_order=true URL gets cached. Stripe redirects the customer back, the cached HTML serves a stale order_id, and the confirmation handler bails because the payment_intent client_secret no longer matches.

Third, this is the one I hit on the client project, Stripe webhooks are racing the customer redirect. The payment_intent.succeeded webhook fires before the customer finishes the redirect. WooCommerce's order status updates to "Processing", then the customer-side return handler tries to update it again from "Pending" and throws an order_status_invalid exception. The customer sees an error, even though money has already moved.

The Fix

Step 1: Exclude every pay_for_order URL from page cache. Add this to your wp-config.php above the ABSPATH line so cache plugins see it before they store the response:

if (
    isset( $_GET['pay_for_order'] ) ||
    isset( $_GET['payment_intent'] ) ||
    isset( $_GET['payment_intent_client_secret'] )
) {
    define( 'DONOTCACHEPAGE', true );
    define( 'DONOTCACHEOBJECT', true );
}

If you are on Cloudflare with a "Cache Everything" rule, add a Page Rule to bypass cache on /checkout/order-pay/* and on any URL containing payment_intent. WP Rocket, LiteSpeed, and W3 Total Cache all respect DONOTCACHEPAGE, but CDN caches do not, so the rule has to live at both layers.

Step 2: Lock order status updates to one source of truth. The race condition between webhook and redirect is the biggest cause of the loop. Force the redirect handler to skip if the webhook already processed the order:

add_action( 'woocommerce_thankyou', function ( $order_id ) {
    $order = wc_get_order( $order_id );
    if ( ! $order ) {
        return;
    }

    $intent_id = $order->get_meta( '_stripe_intent_id' );
    if ( ! $intent_id ) {
        return;
    }

    if ( in_array( $order->get_status(), [ 'processing', 'completed' ], true ) ) {
        return;
    }

    $intent = WC_Stripe_API::request_with_level3_data(
        [],
        "payment_intents/{$intent_id}",
        'GET'
    );

    if ( 'succeeded' === ( $intent->status ?? '' ) && 'pending' === $order->get_status() ) {
        $order->payment_complete( $intent->id );
    }
}, 5 );

This snippet runs before the default thankyou handler at priority 5, fetches the actual Stripe status, and only completes the order if the webhook has not already done it. No more dueling status updates.

Step 3: Verify the webhook endpoint. Go to WooCommerce → Settings → Payments → Stripe → Webhook. The endpoint must be https://yourdomain.com/?wc-api=wc_stripe. Send a test payment_intent.succeeded event from the Stripe Dashboard. If the test returns 200 OK and the order status updates correctly, the webhook side is healthy.

Step 4: Hard-code the return URL on the payment intent. In some themes the JS redirect_url is empty because of a custom checkout. Filter it server-side so Stripe always knows where to send the customer back:

add_filter( 'wc_stripe_payment_intent_args', function ( $args, $order ) {
    $args['return_url'] = $order->get_checkout_order_received_url();
    return $args;
}, 10, 2 );

Step 5: Test in incognito with a 3DS test card. Use Stripe's authentication-required test card 4000 0027 6000 3184. If the loop still happens, open DevTools, Network tab, and watch for a 304 Not Modified on /checkout/order-pay/. If you see one, your cache is still serving stale HTML. Go back to Step 1 and check your CDN cache rules.

For deeper debugging, the Stripe Payment Intents lifecycle docs show every state transition, which is gold when you are tracing exactly where the flow breaks.

The Lesson

3D Secure loops are almost always cache plus race condition, not a Stripe bug. Exclude every URL that touches a payment intent, let the webhook be the source of truth for order status, and make the redirect handler skip if the order is already processing.

If your store is bleeding sales to failed 3DS checkouts right now, I clean this up fast — see my services. For a related Stripe issue I covered the WooCommerce Stripe webhook signature mismatch earlier.

Back to blogStart a project