How to prevent spam orders, spam registrations, and repeated failed payments in WooCommerce, programatically.

One of my clients’ sites attracts quite a lot of visitors and includes a WooCommerce store. Inevitably, they also see quite a few spammy registrations, spammy orders, and repeated failed payments from bots testing card details.

Whilst this spam is not necessarily the end of the world, the client ends up receiving a lot of email notifications that they don’t need. It feels like something we can improve.

As usual in my bloody-mindedness I want to figure out how we can tackle this without installing additional plugins on the site.

The below solutions are based on the traditional PHP WooCommerce checkout templates, not the block-based checkout.

I’ve come up with two possible solutions.

Solution 1: Require Users To Verify Their Email

When we looked at the spam orders, it looked as though most of them were using fake or throwaway email addresses. Therefore it makes sense to ask genuine customers to verify their email address before allowing them to checkout.

This proved to be a very effective solution, however it requires new customers to jump through additional hoops.

They are allowed to shop as normal as a guest but when they get to the checkout screen they must either log in or register to continue.

Registration is fairly quick and easy. They simply enter their email address in the registration form on the site and WC sends them an email notification containing a link to set their password.

We can use a bit of custom code to prevent them from checking out until they have set their password.

This effectively allows us to confirm that they are using a genuine email address, and are more likely to be a genuine customer.

Implementation

Step 1

For starters, we need to disable guest checkouts in WooCommerce, which can be done via the WooCommerce > Settings > Accounts & Privacy page.

This will have the effect of preventing the display of the checkout form. Users will be shown a You must be logged in to checkout. message.

Step 2

By default WC will also show a Returning customer? Click here to login message, which allows users to log in and continue.

What WC doesn’t do by default is include a link to the registration page, which would be helpful for users that don’t have an account already.

We can therefore add the following to the mytheme/woocommerce/checkout/form-login.php WC template. If you haven’t overridden this template already, you’ll need to copy it from the WC plugin’s templates folder into your theme.

wc_print_notice('New customer? <a href="' . wc_get_page_permalink( 'myaccount' ) . '">Click here to register.</a>', 'notice');

Add this beneath the existing WC notice, e.g:

if ( $login_reminder_at_checkout ) : ?>

  <div class="woocommerce-form-login-toggle">
		
    <?php
    wc_print_notice(
      apply_filters( 'woocommerce_checkout_login_message', esc_html__( 'Returning customer?', 'woocommerce' ) ) . // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
      ' <a href="#" class="showlogin">' . esc_html__( 'Click here to login', 'woocommerce' ) . '</a>',
      'notice'
    );
    ?>

  </div>

  <?php
  wc_print_notice('New customer? <a href="' . wc_get_page_permalink( 'myaccount' ) . '">Click here to register.</a>', 'notice');

endif;

This should display an extra notice beneath the default one directing customers to register.

Step 3

Next we need to introduce some way of actually preventing unverified users from checking out. I have done this by adding an extra mytheme_unverified meta data to ther user’s account when customers register.

The below can be added to functions.php or within a custom plugin.

First the code that adds the “unverified” flag when a new customer is created:

add_action('woocommerce_created_customer', function($customer_id, $new_customer_data, $password_generated) {

  // Add the "unverified" flag when a new customer is created

  update_user_meta($customer_id, 'mytheme_unverified', true);

}, 10, 3);

Next the code that removes the “unverified” flag when a user sets their password:

add_action('woocommerce_customer_reset_password', function($user) {

  // Remove the unverified flag when the user resets their password

  delete_user_meta($user->ID, 'mytheme_unverified');

}, 10, 1);

Bonus: we also can use the unverified flag to bulk delete any unverified users at regular intervals:

function mytheme_wc_prune_unverified_customers() {

  $args = [
    'meta_key' => 'mytheme_unverified',
    'meta_value' => true
  ];

  $user_query = new WP_User_Query($args);
  $unverified_users = $user_query->get_results();

  if (!empty($unverified_users)) {

    require_once ABSPATH . 'wp-admin/includes/user.php';

    $count = 0;

    foreach ($unverified_users as $user) {

      if (!wp_delete_user($user->ID)) {

        // Do something here if the user could not be deleted

      } else {

        $count++;

      }

    }

    // Send a notification / log message here with the total number of unverified accounts that have been deleted

  }

}

if (!wp_next_scheduled('mytheme_wc_cron_weekly')) {

  wp_schedule_event(time(), 'weekly', 'mytheme_wc_cron_weekly');

}

add_action('mytheme_wc_cron_weekly', function() {

  mytheme_wc_prune_unverified_customers();

});

Step 4

Finally, we need to alter the mytheme/woocommerce/checkout/form-checkout.php template to prevent unverified users from checking out:

<?php
/**
 * Checkout Form
 *
 * This template can be overridden by copying it to yourtheme/woocommerce/checkout/form-checkout.php.
 *
 * HOWEVER, on occasion WooCommerce will need to update template files and you
 * (the theme developer) will need to copy the new files to your theme to
 * maintain compatibility. We try to do this as little as possible, but it does
 * happen. When this occurs the version of the template file will be bumped and
 * the readme will list any important changes.
 *
 * @see https://docs.woocommerce.com/document/template-structure/
 * @package WooCommerce\Templates
 * @version 9.4.0
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

do_action( 'woocommerce_before_checkout_form', $checkout );

$user_is_unverified = get_user_meta(get_current_user_id(), 'mytheme_unverified', 1);

if ($user_is_unverified) {

  wc_print_notice('Please verify your email address before placing your order. To do this, click the link to reset your password in the welcome email we sent you.', 'notice');

  return;

}

// Template continues...

As I’ve mentioned, this is a very effective solution, although it does not prevent spammy registrations entirely, it allows us to halt spammers using fake email accounts, and it allows us to manage the removal registered users that have not verified their email address.

The downside to this solution is that it introduces extra friction to the checkout process that might discourage genuine customers.

Solution 2: Use Cloudflare Turnstile and limit the number of failed payments a customer can make

If we are going to allow guest checkouts, we need some way of figuring out whether the visitor is human, and we need some way of preventing even human spammers from attempting multiple payments.

Initially I thought about using Google reCaptcha for the bot checking, however it is not GDPR compliant and it sets third party cookies which would mean we wouldn’t be able to use it if visitors had rejected non-essential cookies.

Thankfully Cloudflare Turnstile works on a very similar principle, but is compliant.

Implementation

Step 1

The first thing we need to do is register for Turnstile and obtain some API keys.

Step 2

Next we need to load the Turnstile script on the checkout page:

add_action('wp_enqueue_scripts', 'mytheme_enqueue_wc_things');

function mytheme_enqueue_wc_things() {

  if (is_checkout()) {

    wp_enqueue_script('cf-turnstile', 'https://challenges.cloudflare.com/turnstile/v0/api.js', [],null, true);

  }

}

Step 3

Then we add the Turnstile widget to the checkout page:

add_action('woocommerce_checkout_before_order_review', 'mytheme_add_turnstile_widget');

function mytheme_add_turnstile_widget() {

  ?>
  <div class="cf-turnstile"
    data-sitekey="YOUR SITE KEY HERE"
    data-theme="light"
    data-size="invisible"
    data-callback="onTurnstileSuccess"></div>

  <input type="hidden" name="cf-turnstile-response" id="cf-turnstile-response">

  <script>

    function onTurnstileSuccess(token) {

      document.getElementById('cf-turnstile-response').value = token;

    }

  </script>
  <?php

}

Step 4

Next we need to get WooCommerce to do the Turnstile challenge and retreive a token before the order is submitted.

add_action('wp_footer', 'mytheme_do_turnstile_challenge');

function mytheme_do_turnstile_challenge() {

  if (!is_checkout()) return;
  ?>
  <script>
    jQuery(function($) {

      $('form.checkout').on('checkout_place_order', function() {

        // Prevent form submission until Turnstile completes

        const $form = $(this);

        if (!$('#cf-turnstile-response').val()) {

          return new Promise(resolve => {

            turnstile.execute(); // triggers invisible challenge

            const check = setInterval(() => {

              if ($('#cf-turnstile-response').val()) {

                clearInterval(check);
                resolve(true);

              }

            }, 100);

          });

        }

        return true;

      });

    });
  </script>
  <?php

}

Step 5

The final part of the Turnstile integration – we need to actually validate the token server side.

add_action('woocommerce_checkout_process', 'mytheme_do_turnstile_validation');

function mytheme_do_turnstile_validation() {

  if (empty($_POST['cf-turnstile-response'])) {

    wc_add_notice('Sorry, our system seems to think you are a robot. Please try again later. If the issue persists please contact us.', 'error');

    return;

  }

  $response = wp_remote_post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
    'body' => [
      'secret'   => 'YOUR SECRET',
      'response' => sanitize_text_field($_POST['cf-turnstile-response']),
      'remoteip' => $_SERVER['REMOTE_ADDR'],
    ],
  ]);

  $data = json_decode(wp_remote_retrieve_body($response));

  if (empty($data->success)) {

    wc_add_notice('Sorry, our system seems to think you are a robot. Please try again later. If the issue persists please contact us.', 'error');

  }

}

Step 6

On to the failed payment rate limiting. The below code will set a transient with a hash of the failed payee’s IP address. If the payment has failed 3 or more times, a block transient is set on the IP. It is then just a case of checking the visitor’s IP address during the checkout process and emitting an error if they are blocked.

The transient keeping track of an IP’s failed payments lives for up to 1 hour. The transient setting the block lasts for up to 2 hours.

add_action('woocommerce_payment_failed', 'mytheme_record_failed_payment');

function mytheme_record_failed_payment($order_id) {

  // Log the failed payee in a transient

  $ip = $_SERVER['REMOTE_ADDR'];
  $key = 'failed_pay_' . md5($ip);
  $count = (int) get_transient($key);
  set_transient($key, $count + 1, HOUR_IN_SECONDS);

  if ($count + 1 >= 3) {

    set_transient('block_ip_' . md5($ip), true, HOUR_IN_SECONDS * 2);

  }

}

add_action('woocommerce_checkout_process', 'mytheme_block_failed_payments');

function mytheme_block_failed_payments() {

  $ip = $_SERVER['REMOTE_ADDR'];

  if (get_transient('block_ip_' . md5($ip))) {

    wc_add_notice('Sorry, we cannot process your order because we have seen multiple failed payments from your IP address. Please try again later.', 'error');

  }

}
Mobile Menu