A WooCommerce checkout is a public, scriptable endpoint with a known shape on well over a million stores. That is why card testing finds it: a script posts stolen card numbers through the ordinary order flow, keeps the ones that authorise, and leaves you with the gateway fees and the chargebacks. Registration spam and coupon farming arrive through the same door.

The fix is two hooks and one HTTP call. What makes it fail in production is never the hook — it is a page cache serving a stale nonce, a wp_remote_post with WordPress's default five-second timeout in front of a customer, and refusing the order in the wrong place so the payment has already been taken.

Load the collector

Enqueue the script so it is present on the pages that matter, rather than pasting a tag into a theme file that the next update overwrites:

// wp-content/plugins/store-guard/store-guard.php
add_action('wp_enqueue_scripts', function () {
    if (is_checkout() || is_account_page()) {
        wp_enqueue_script(
            'maskbreak',
            'https://maskbreak.com/assets/sentinel.js',
            [], null, true
        );
    }
});

The registration form and the checkout form both need a hidden field for the token. WooCommerce gives you a hook on each:

add_action('woocommerce_register_form', 'sg_token_field');
add_action('woocommerce_after_order_notes', 'sg_token_field');

function sg_token_field() {
    echo '<input type="hidden" name="monocle" '
       . 'class="monocle-enriched" value="">';
}

One function for the call

WordPress ships an HTTP client, so there is no dependency to add. There is a default to override: wp_remote_post waits five seconds, which is an eternity in front of a checkout button.

function sg_evaluate($token, $email = null) {
    if (empty($token)) {
        return null;   // no opinion — never "safe"
    }

    $res = wp_remote_post('https://maskbreak.com/v1/evaluate', [
        // Default is 5s. Far too long in front of a customer.
        'timeout' => 2,
        'headers' => [
            'Authorization' => 'Bearer ' . SG_API_KEY,
            'Content-Type'  => 'application/json',
        ],
        'body' => wp_json_encode(array_filter([
            'token' => $token,
            'email' => $email,
        ])),
    ]);

    if (is_wp_error($res)) {
        // Timeout, DNS, connection reset. Weather.
        error_log('maskbreak down: ' . $res->get_error_message());
        return null;
    }

    $code = wp_remote_retrieve_response_code($res);
    if ($code >= 400) {
        // Our bug: bad key, malformed body. Will not fix itself.
        error_log('maskbreak rejected request: HTTP ' . $code);
        return null;
    }

    return json_decode(wp_remote_retrieve_body($res), true);
}

The key belongs in wp-config.php as a constant, not in the options table where it lands in every database export you send to a developer:

// wp-config.php
define('SG_API_KEY', 'sk_live_your_key_here');

Registration: the easy half

registration_errors runs before the account is created, and returning a WP_Error stops it there:

add_filter('registration_errors', function ($errors, $login, $email) {
    $verdict = sg_evaluate($_POST['monocle'] ?? '', $email);

    // No verdict: ad blocker, or the API is down. Let it through
    // and count it. A detection outage must not close registration.
    if (!$verdict) {
        return $errors;
    }

    if ($verdict['decision'] === 'block') {
        $errors->add('sg_blocked',
            __('Registration is unavailable from this connection.'));
    }

    return $errors;
}, 10, 3);

Refuse on block only. A plain VPN comes back as review, and turning away every customer who uses one to catch a handful of abusers is a trade that almost never pays.

Checkout: refuse before the charge, not after

This is where the money is, and where the hook choice matters most. woocommerce_after_checkout_validation runs after the fields validate and before the payment gateway is called. Adding an error here stops the order with nothing charged and no order row written.

add_action('woocommerce_after_checkout_validation',
function ($data, $errors) {
    $verdict = sg_evaluate($_POST['monocle'] ?? '',
                           $data['billing_email'] ?? null);
    if (!$verdict) return;

    if ($verdict['decision'] === 'block') {
        $errors->add('sg_blocked',
            __('We cannot process this order. Contact support.'));
        return;
    }

    if ($verdict['decision'] === 'review') {
        // Not enough to refuse. Enough to hold and look.
        WC()->session->set('sg_review', true);
    }
}, 10, 2);

Then park the review band in on-hold rather than letting it flow to processing:

add_action('woocommerce_checkout_order_processed',
function ($order_id) {
    if (WC()->session->get('sg_review')) {
        $order = wc_get_order($order_id);
        $order->update_status('on-hold',
            __('Flagged for review: elevated risk signals.'));
        WC()->session->set('sg_review', null);
    }
});

Doing this in woocommerce_thankyou or on an order-status transition instead is the common mistake, and it is expensive: by then the gateway has authorised, you have paid the fee, and a card-testing script has already got the answer it came for. The point of card testing is the authorisation response, not the goods.

The cheap control that matters more than any API

Card testing is a volume attack, and the strongest single defence is a limit on failed payment attempts — before any vendor gets involved:

add_action('woocommerce_checkout_order_processed',
function ($order_id) {
    $ip  = sg_client_ip();
    $key = 'sg_fail_' . md5($ip);
    $n   = (int) get_transient($key);

    if ($n >= 5) {
        wp_die(__('Too many attempts. Please try again later.'),
               '', ['response' => 429]);
    }
});

add_action('woocommerce_order_status_failed', function ($order_id) {
    $order = wc_get_order($order_id);
    $key   = 'sg_fail_' . md5($order->get_customer_ip_address());
    $n = (int) get_transient($key);
    set_transient($key, $n + 1, HOUR_IN_SECONDS);
});

Five failures an hour from one address removes most of the value of the attack for nothing. It is not sufficient on its own — a residential proxy network rotates addresses precisely to defeat per-IP counting, which is the gap the screening call fills — but it is the first thing to ship, and many stores never do.

The caching problem that breaks all of this

WooCommerce tells page caches to skip cart, checkout and account pages, and plenty of stacks ignore it: an over-eager Cache-Enabler rule, a CDN page rule that matches too broadly, or an optimisation plugin that decided your checkout looked static. When the checkout is served from cache, everything downstream is wrong. The nonce is stale, so WooCommerce rejects submissions with an unhelpful error; the token field arrives empty for every visitor, so your screening silently sees nothing and lets everything through.

Check it directly rather than trusting the plugin's settings screen. From a terminal:

curl -sI https://example.com/checkout/ | \
  grep -iE 'cache|age|cf-cache-status'

Anything reporting a hit, or an age above zero, means the page is being cached and the screening is decorative. Exclude /cart/, /checkout/, /my-account/ and any URL containing wc-ajax at every layer — plugin, host and CDN — because it only takes one of the three to ruin it.

Object caching is fine and helps. It is page caching that does the damage.

Two more WordPress-shaped traps

Checkout blocks versus the shortcode. Newer stores use the Cart and Checkout blocks, where woocommerce_after_order_notes does not fire and there is no PHP-rendered form to inject a field into. The server-side validation hook still runs, so the fix is on the client: register an additional checkout field through the blocks API, or fall back to the classic shortcode checkout. Confirm which one your store renders before assuming the field is there — view the source and look for the hidden input.

Other plugins hooking the same filters. Security plugins commonly attach to registration_errors too, and priority decides who runs first. Keep yours at the default 10 and check for double-refusal: two plugins adding errors for the same request produces a confusing wall of messages, and users read none of them.

The short version

  • Two hooks: registration_errors for accounts, woocommerce_after_checkout_validation for orders.
  • Validation runs before the gateway. Refusing later means you already paid for the authorisation the attacker wanted.
  • Override the wp_remote_post timeout. Five seconds in front of a checkout button is not a default you can keep.
  • Refuse on block; put review orders on-hold. A plain VPN is a customer.
  • Rate-limit failed payments per address first. It is free and it removes most of the attack's value.
  • Verify the checkout is not page-cached. A cached form means a stale nonce and an empty token, and screening that never sees anything.
  • Store the key in wp-config.php, never in the options table.
FAQ

Frequently Asked Questions

How do I stop card testing on WooCommerce?

Rate-limit failed payment attempts per address first — five an hour removes most of the value for nothing. Then screen the session in woocommerce_after_checkout_validation, which runs before the payment gateway is called, so a refusal costs no authorisation fee. Refusing later, at thankyou or on a status change, is too late: the attacker already got the authorisation response, which is the entire product they came for.

Which WooCommerce hook should the fraud check run on?

woocommerce_after_checkout_validation for orders and registration_errors for accounts. Both run before the thing you want to prevent — the charge and the user row respectively — and both take an error object, so refusing is a matter of adding a message rather than dying mid-request.

Why does my checkout screening never see a token?

Almost always page caching. If a plugin, host layer or CDN caches /checkout/, every visitor gets the same stale HTML: the nonce is wrong and the token field is empty, so your check silently passes everything. Curl the checkout URL and look at the cache headers rather than trusting a settings page. The other cause is the block-based checkout, where woocommerce_after_order_notes does not fire and the field has to be registered through the blocks API.

Will this slow down my checkout?

One HTTPS call with a two-second ceiling, typically answering in well under a hundred milliseconds. Set the timeout explicitly, because wp_remote_post defaults to five seconds and you do not want that in front of a customer. When the call fails, return no verdict and let the order proceed — an outage in a fraud check must not close your store.

Two hooks in front of your checkout

Maskbreak returns a decision, a score and the reasons behind it before the gateway is called. Free tier: 1,000 requests per hour, no credit card.

Try Maskbreak free →