On this page
A WooCommerce checkout is a public, scriptable endpoint with the same shape on a very large number of 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 a small plugin: the script on the checkout and account pages, one HTTP call, and two hooks. What makes it fail in production is rarely the hook. It is a form that never gets the browser evidence, 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, after the payment has already been taken. This guide follows the three steps the Maskbreak console lists (add the script to the page with your form, send the check from your server, submit your form once) and starts in watch mode, where every check is logged and nothing is refused.
It covers the classic checkout (the [woocommerce_checkout] shortcode) and the My Account registration form. The Cart and Checkout blocks are covered at the end.
Step 1: Add the script to the page with your form
Enqueue the script on the checkout and account pages from a small plugin, rather than pasting a tag into a theme file that the next update overwrites. The script fills only forms that carry class="monocle-enriched" on the <form> element itself, and WooCommerce renders both forms without it, so one inline line adds the class before the script runs. Never put the class on a hidden input: the input stays empty, and an empty monocle field on the page also hides the real one from Sentinel.collect().
<?php
/**
* Plugin Name: Store Guard
* Description: Maskbreak checks on checkout and registration.
*/
// wp-content/plugins/store-guard/store-guard.php
add_action('wp_enqueue_scripts', function () {
if (!function_exists('is_checkout') || !(is_checkout() || is_account_page())) {
return;
}
// In the footer, so both forms are already on the page when it runs.
wp_enqueue_script('maskbreak', 'https://maskbreak.com/assets/sentinel.js', [], null,
['in_footer' => true, 'strategy' => 'async']);
// The class goes on the <form> elements themselves, before the script runs.
wp_add_inline_script('maskbreak',
"document.querySelectorAll('form.woocommerce-checkout, form.woocommerce-form-register')"
. ".forEach(function (f) { f.classList.add('monocle-enriched'); });",
'before');
});
The script then adds three hidden fields to those forms: monocle (the network token), sentinel_fp (the browser check id) and sentinel_tz (the time zone). The classic checkout posts every field of its form, so they reach PHP in $_POST. If your site sends a Content-Security-Policy (a security plugin, or your host), add https://maskbreak.com, https://mcl.maskbreak.com, https://*.mcl.maskbreak.com, https://mesu.maskbreak.com and 'wasm-unsafe-eval' to script-src, the mcl hosts (https and wss) and mesu to connect-src, and https://mesu.maskbreak.com blob: to worker-src. If an optimisation plugin delays or combines JavaScript, exclude sentinel.js and the inline line above from it.
Step 2: Send the check from your server
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, above "That's all, stop editing!"
define('MASKBREAK_API_KEY', 'sk_live_your_key_here');
// define('MASKBREAK_MODE', 'enforce'); // later; unset means watch
WordPress ships an HTTP client, so there is no dependency to add. The function maps the form's names to the API's (monocle to token, sentinel_fp to fingerprintEventId, sentinel_tz to tz) and calls even when a field is missing: a check that arrives without a token or a browser check id is how the console tells you which half is missing. It runs once per request, whichever hook asks first, and it never throws.
// store-guard.php (continued)
function sg_mode() {
return defined('MASKBREAK_MODE') ? MASKBREAK_MODE : 'watch';
}
function sg_field($name) {
return isset($_POST[$name]) && is_string($_POST[$name])
? sanitize_text_field(wp_unslash($_POST[$name])) : '';
}
/** Returns the verdict (an array), or null when there is none. */
function sg_check($email = '') {
static $done = false, $verdict = null;
if ($done) {
return $verdict; // one check per request
}
$done = true;
$body = ['token' => sg_field('monocle')]; // sent even when it is empty
$optional = [
'fingerprintEventId' => sg_field('sentinel_fp'),
'tz' => sg_field('sentinel_tz'),
'email' => is_string($email) ? $email : '',
'accountId' => is_user_logged_in() ? (string) get_current_user_id() : '',
];
foreach ($optional as $key => $value) {
if ($value !== '') {
$body[$key] = $value;
}
}
$res = wp_remote_post('https://maskbreak.com/v1/evaluate', [
'timeout' => 3, // the default is 5 s: long in front of a checkout button
'headers' => [
// A missing constant is a 401 in the log, never a fatal error at checkout.
'Authorization' => 'Bearer ' . (defined('MASKBREAK_API_KEY') ? MASKBREAK_API_KEY : ''),
'Content-Type' => 'application/json',
],
'body' => wp_json_encode($body),
]);
if (is_wp_error($res)) {
error_log('[maskbreak] unavailable: ' . $res->get_error_code()); // weather
} elseif ((int) wp_remote_retrieve_response_code($res) !== 200) {
// 401 is the key, 400 the body: a bug that will not fix itself.
error_log('[maskbreak] HTTP ' . wp_remote_retrieve_response_code($res));
} else {
$json = json_decode(wp_remote_retrieve_body($res), true);
$verdict = is_array($json) ? $json : null;
}
// One line per check. Never log the key, the token or form data.
error_log('[maskbreak] ' . wp_json_encode([
'mode' => sg_mode(),
'decision' => $verdict['decision'] ?? null,
'reasons' => $verdict['reasons'] ?? null,
'device_evidence' => $verdict['device_evidence'] ?? null,
'degraded' => $verdict === null ? null : !empty($verdict['degraded']),
]));
return $verdict;
}
Wire it into your own app: a free key returns decision, risk_score and reasons for every visit, 10,000 checks a month on the Free plan, no card.
Get an API keyRegistration
The My Account form runs woocommerce_registration_errors before the customer is created, and adding an error there stops it:
add_filter('woocommerce_registration_errors', function ($errors, $username, $email) {
$verdict = sg_check($email);
// Watch mode, or no verdict (a blocked script, an outage): never refuse.
if (sg_mode() === 'enforce' && ($verdict['decision'] ?? null) === '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 rarely 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.
add_action('woocommerce_after_checkout_validation', function ($data, $errors) {
$verdict = sg_check($data['billing_email'] ?? '');
if (sg_mode() !== 'enforce' || !$verdict) {
return; // watch mode, or no verdict: the order goes ahead
}
if ($verdict['decision'] === 'block') {
$errors->add('sg_blocked', __('We cannot process this order. Contact support.'));
} elseif ($verdict['decision'] === 'review') {
WC()->session->set('sg_review', true); // not enough to refuse; enough to hold
}
}, 10, 2);
A review order should not flow straight to fulfilment. Mark it when it is created, then keep it on-hold when the payment completes, which is the moment WooCommerce would otherwise move it to processing:
add_action('woocommerce_checkout_order_processed', function ($order_id, $posted, $order) {
if (WC()->session && WC()->session->get('sg_review')) {
$order->update_meta_data('_sg_review', 'yes');
$order->add_order_note(__('Maskbreak answered review: check before fulfilling.'));
$order->save();
WC()->session->set('sg_review', null);
}
}, 10, 3);
add_filter('woocommerce_payment_complete_order_status', function ($status, $order_id, $order) {
return ($order && $order->get_meta('_sg_review') === 'yes') ? 'on-hold' : $status;
}, 10, 3);
Refusing 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.
Step 3: Submit your form once
Activate the plugin, open the checkout in an ordinary browser, wait a moment, and place one order with an offline method such as bank transfer or cash on delivery (or register an account on My Account). The first check then appears in the Maskbreak console (if half of it did not arrive, the console says which half), and the PHP error log (wp-content/debug.log with WP_DEBUG_LOG on, or your host's log) shows one [maskbreak] line. "degraded":true means no network token arrived: the script was blocked, a cache or optimisation plugin got in the way, or the class is missing. "device_evidence":"not_sent" means no browser check id arrived; "ok" with degraded false is a complete check.
After a few days of comparing those lines with the console's Events, add define('MASKBREAK_MODE', 'enforce'); to wp-config.php. The same hooks then refuse a block and hold a review.
The cheap control that needs no API
Card testing is a volume attack, and a limit on failed payment attempts per address costs nothing and needs no vendor. It runs in the same validation hook, before the gateway:
add_action('woocommerce_after_checkout_validation', function ($data, $errors) {
$key = 'sg_fail_' . md5(WC_Geolocation::get_ip_address());
if ((int) get_transient($key) >= 5) {
$errors->add('sg_rate', __('Too many attempts. Please try again later.'));
}
}, 5, 2);
add_action('woocommerce_order_status_failed', function ($order_id) {
$order = wc_get_order($order_id);
if (!$order) {
return;
}
$key = 'sg_fail_' . md5($order->get_customer_ip_address());
set_transient($key, (int) get_transient($key) + 1, HOUR_IN_SECONDS);
});
Five failures an hour from one address slows a single-address script to five tries an hour. It is not sufficient on its own: a residential proxy network rotates addresses precisely to defeat per-address counting, which is the gap the screening call fills. But it is the first thing to ship.
The caching problem
WooCommerce tells page caches to skip cart, checkout and account pages, and plenty of stacks ignore it: an over-eager cache 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, the nonce is stale and WooCommerce rejects submissions with an unhelpful error. Check it directly rather than trusting a settings screen:
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. 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. Object caching is fine; it is page caching that does the damage.
The Cart and Checkout blocks
Newer stores use the Cart and Checkout blocks. They submit through WooCommerce's Store API, so the classic form, the inline line that marks it and woocommerce_after_checkout_validation are not part of that path, and this plugin does not check those orders. View the checkout page's source: a form.woocommerce-checkout means the classic checkout. Until you pass the evidence through the Store API yourself, switch the checkout page back to the [woocommerce_checkout] shortcode on stores that need the check.
Security plugins often attach to the same registration filter, 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 produce a wall of messages that nobody reads.
The short version
- The class goes on the
<form>, never on an input, and the script loads on the checkout and account pages. - Forward
monocle,sentinel_fpandsentinel_tzastoken,fingerprintEventIdandtz, and call every time, even without a token. Watch mode never refuses. - Two hooks:
woocommerce_registration_errorsfor accounts,woocommerce_after_checkout_validationfor orders. Validation runs before the gateway. - Override the
wp_remote_posttimeout. Five seconds in front of a checkout button is not a default to keep. - In enforce mode, refuse on
block; holdrevieworderson-hold. A plain VPN is a customer. - Rate-limit failed payments per address first, keep the checkout out of every page cache, and know whether your store runs the classic checkout or the blocks.
- Store the key in
wp-config.php, never in the options table.
Questions people ask
- How do I stop card testing on WooCommerce?
- Rate-limit failed payment attempts per address first; it costs nothing. Then send the check in woocommerce_after_checkout_validation, which runs before the payment gateway is called, so a refusal in enforce mode 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 what card testing is for.
- Which WooCommerce hook should the fraud check run on?
- woocommerce_after_checkout_validation for orders and woocommerce_registration_errors for accounts made on the My Account page. Both run before the thing you want to prevent (the charge and the customer record), 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?
- Check three things. The class monocle-enriched must sit on the form element itself (form.woocommerce-checkout), never on a hidden input, which stays empty. The checkout must be the classic shortcode one: the Cart and Checkout blocks post through the Store API, where these hooks do not run. And no page cache or optimisation plugin may cache the checkout or delay sentinel.js; curl the checkout URL and read the cache headers rather than trusting a settings page.
- Will this slow down my checkout?
- One HTTPS call with a three-second ceiling; each response reports its own server processing time, so measure your own traffic. 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.
Paste it in, then watch the verdicts
The public sk_test_sandbox key returns the documented allow, review and block shapes with no account, so the failure path is testable before you go live. SDKs for Node, Python and PHP, or plain HTTP. The API reference and the Free plan cover the rest.



