The fake accounts this guide deals with are made by scripts that fill in every field they find and send the form straight back. That behaviour gives you three checks you can run on your own server, with no CAPTCHA and no outside service: a hidden field only a machine fills in, a clock that refuses a form sent back too quickly, and a limit on new accounts from one address. Before any of that, switch off the sign-up forms you do not actually use.
What fake accounts cost you#
The accounts themselves do little harm sitting in the database. The emails they cause are the expensive part.
- WordPress's own registration sends two emails for every new account: one to the site's administrator, and one to the new address with a link to set the password.
- WooCommerce sends its New account email to every customer who registers.
A made-up address bounces, and a real one somebody else typed in gets an email its owner never asked for. Mail services watch bounces closely: Amazon SES, for one, explains in its sending review FAQ that high bounce rates can send your email to spam, and that it can put an account under review or pause its sending because of them. The same mail service carries your order confirmations. On top of that, the fake accounts fill your customer list and any newsletter tool that syncs from it.
Switch off the forms you do not need#
A form that is not there cannot be abused, so check what is open first.
- Settings → General, under Membership: untick Anyone can register unless people really sign up to your site through WordPress itself. With it off,
wp-login.php?action=registersends visitors towp-login.php?registration=disabledinstead. WooCommerce does not need this box for its own customer accounts. - WooCommerce → Settings → Accounts & Privacy: under Allow customers to create an account, the choices are After checkout (recommended), During checkout and On "My account" page. If customers only need an account to buy, untick On "My account" page. That form is open to anyone without an order, which makes it the cheapest one for a script to use.
- On a multisite network, sign-ups are set for the whole network under Network Admin → Settings → Allow new registrations, and WordPress's registration link goes to
wp-signup.php.
A hidden field and a clock#
A honeypot is an extra text field moved off the screen. People never see it, so they leave it empty. A script that fills in every field fills it in, and gives itself away. The clock does a similar job: a person needs a few seconds to type an email address, a script does not, and a form sent back a day after it was drawn is a copy somebody kept.
This snippet adds both to WordPress's registration form and WooCommerce's My Account registration form. Put it in a small plugin, or in a file in wp-content/mu-plugins/:
<?php
// A hidden field and a signed clock on the registration forms.
add_action( 'register_form', 'myshop_signup_trap' );
add_action( 'woocommerce_register_form', 'myshop_signup_trap' );
function myshop_signup_trap() {
$now = time();
echo '<div style="position:absolute;left:-10000px" aria-hidden="true">'
. '<input type="text" name="myshop_hp" value="" tabindex="-1" autocomplete="off">'
. '</div>';
printf(
'<input type="hidden" name="myshop_ts" value="%s">',
esc_attr( $now . '-' . wp_hash( 'myshop_signup' . $now ) )
);
}
add_filter( 'registration_errors', 'myshop_signup_check' );
add_filter( 'woocommerce_process_registration_errors', 'myshop_signup_check' );
function myshop_signup_check( $errors ) {
$hp = isset( $_POST['myshop_hp'] ) ? (string) wp_unslash( $_POST['myshop_hp'] ) : '';
$ts = isset( $_POST['myshop_ts'] ) ? (string) wp_unslash( $_POST['myshop_ts'] ) : '';
$parts = explode( '-', $ts );
$drawn = (int) $parts[0];
$valid = isset( $parts[1] ) && hash_equals( wp_hash( 'myshop_signup' . $drawn ), $parts[1] );
$age = time() - $drawn;
if ( '' !== $hp || ! $valid || $age < 3 || $age > DAY_IN_SECONDS ) {
$errors->add( 'myshop_signup', 'We could not create the account. Wait a moment and try again.' );
}
return $errors;
}The time is signed with wp_hash(), which uses your site's secret keys, so a script cannot simply send an old time of its own. The hidden field is also kept away from screen readers and the Tab key, so a person using either never lands in it.
Know the limits before you rely on it:
- It checks only the two forms whose hooks it uses. The WooCommerce part uses
woocommerce_process_registration_errors, which only the My Account form runs, so checkout is untouched. - A theme or popup form that posts to
wp-login.php, or registers people through WordPress'sregister_new_user(), without drawing theregister_formhook will have every sign-up refused, because it never carried the two fields. Test every sign-up form you have after adding it. - A registration page served from a page cache keeps an old time. After a day, sign-ups from it are refused until the cache is refreshed.
- A script that runs a real browser and waits, or a person paid to sign up, gets through. These checks remove the cheap, bulk kind.
A limit per address#
The third check counts new accounts from one IP address in an hour and refuses the next one past a small number. It catches a script that does pass the first two and then makes accounts one after another.
It is harder to write well than it looks. Behind Cloudflare or a load balancer, PHP's REMOTE_ADDR is the proxy's address, so every visitor shares one count and the limit can lock out real customers. Cloudflare sends the visitor's address in a CF-Connecting-IP header, which you should only trust on requests that really came through Cloudflare. A shop whose customers share one connection (an office, a school, a mobile network) needs a higher number. And applying the limit at checkout means refusing an order, which needs a clear message. This is the part most worth leaving to a plugin that already handles it.
Email confirmation and throwaway addresses#
WordPress's own registration already confirms the address in effect: the new account has no password its owner knows until they open the emailed link. WooCommerce is different. Its Send password setup link (recommended) option, under Account creation options, emails a link for setting the password, but WooCommerce signs a new customer in straight away, so the account works before anyone has opened that email. A fake account therefore still costs you the email.
Blocking disposable email domains helps a little and costs a little. Public lists of such domains go out of date, and a strict list sooner or later refuses a real customer. If you try it, keep a short list built from the domains you actually see in fake sign-ups.
Clean up the accounts already there#
Back up the database first. Then, with WP-CLI, list the customer accounts that have never placed an order:
wp eval 'foreach ( get_users( array( "role" => "customer", "fields" => "ID" ) ) as $id ) { if ( ! wc_get_customer_order_count( $id ) ) { echo $id . " "; } }'On a site without WooCommerce, wp user list --role=subscriber --fields=ID,user_email,user_registered gives you the list to judge by date instead. Look at a sample of those accounts under Users before deleting anything; a real customer who registered and has not bought yet looks the same. Then delete them, handing anything they own to your own account (replace 1 with your user ID):
wp user delete 123 456 789 --reassign=1WooCommerce also has Retain inactive accounts under WooCommerce → Settings → Accounts & Privacy. It deletes customer and subscriber accounts that have not logged in or placed an order for the period you choose, turning any orders into guest orders. It does not tell fake accounts from quiet real ones, so use it only if that is what you want for every account.
Doing it with Login Security#
Login Security is our sign-in protection plugin, and Settings → Sign-ups → Screen new sign-ups for bots does the three checks above on your own server, with no puzzle and nothing sent to another service. The extra field sits off the edge of the screen, hidden from screen readers and skipped by the Tab key. A form sent back in under three seconds, or more than a day after it was drawn, is turned away, and the time is signed with your site's own secret. Sign-ups from one address in an hour is three by default and can be set from 1 to 20. Each sign-up it turns away is listed under Who has tried → Sign-ups, with the reason.
The forms it covers, as its Sign-ups documentation states them:
- WordPress's own registration,
wp-login.php?action=register, where Anyone can register is ticked: all three checks. - My Account registration on a WooCommerce shop: all three checks.
- Checkout, when a customer creates an account while paying: the limit only.
Checkout gets the limit and nothing else, so a customer paying is never stopped by the field or the clock; the classic and the block checkout are both covered. A sign-up form that another plugin or your theme draws, but which registers people through WordPress's or WooCommerce's own registration, is held to the limit only. A theme popup that posts to wp-login.php without drawing WordPress's register_form hook has every sign-up turned away, and the Sign-ups tab shows it.
What it does not cover: a multisite network's wp-signup.php page, a script working through many IPv6 addresses (the field and the clock still apply), and forms other plugins draw and handle themselves, such as Ultimate Member, WPForms or Gravity Forms, whose own anti-spam settings are the ones to use. It does not check whether an email address is real, and it does not stop a person making an account by hand. The screening is on for sites that install it now; a site that already had the plugin is asked on its Dashboard first.
Check it worked#
- In a private window, register on each form you kept open with an address you can read. Take a few seconds over it: the account should be created.
- Now open the form and send it within a second or two, by pasting a new address and pressing Enter at once. With a clock in place, it should be refused.
- Place a test order that creates an account at checkout, and confirm it goes through.
- If you use a theme or popup sign-up form, test that one too.
- A week later, compare the number of new customer accounts with the week before, and look at your mail service's bounce list.
The plugin for this
Login Security
Stop the machines guessing at your passwords, and see exactly who has been trying.
From $19 a year · every feature · 30-day refund

In the documentation
- Sign-ups Documentation
- The log Documentation
- Privacy Documentation
- Does it stop fake sign-ups? Knowledge base
- Why not just use a CAPTCHA? Knowledge base