Stopping fake WordPress registrations with an email check
· 6 min read · Tutorials
To stop fake WordPress registrations by email, hook the registration_errors filter, send the address to an email-classification API from PHP with wp_remote_post and a short timeout, and add a WP_Error when the address is disposable or machine-generated. Hook woocommerce_registration_errors the same way if WooCommerce handles signups. If the API can't be reached or returns an error, let the registration through: an outage should never lock out real users.
The whole thing is one must-use plugin file. It keeps the API key in wp-config.php, backs off on HTTP 429, and caches the domain category so a flood from one throwaway domain doesn't burn your rate limit.
What the check catches, and what it doesn't
Fake registrations on WordPress mostly come from scripts hitting wp-login.php?action=register or the WooCommerce My Account form. An email check helps with two kinds:
- Disposable domains like
mailinator.comoryopmail.fr. - Machine-generated addresses like
ccjxx90sze@gmail.com, where the domain is real but the inbox name is noise.
It does nothing against a bot that registers jane.smith1990@gmail.com. For that you still want a challenge on the form (a CAPTCHA plugin or Cloudflare Turnstile) and email confirmation. Use the email check as one layer, not the only one. The broader picture of who creates fake accounts is in fake signups: where they come from.
Step 1: store the key in wp-config.php
Get a free key from the pingutil dashboard, then add it above the "That's all, stop editing" line:
// wp-config.php
define( 'PINGUTIL_API_KEY', 'pk_live_your_key_here' );
A constant in wp-config.php stays server-side, survives plugin updates, and stays out of database exports. The plugin below does nothing if the constant is missing, so local and staging sites keep working without a key.
Step 2: the must-use plugin
Save this as wp-content/mu-plugins/pingutil-registration-check.php. Must-use plugins load automatically and can't be deactivated from the admin screen, which is what you want for a security check.
<?php
/**
* Plugin Name: pingutil registration check
* Description: Rejects disposable and machine-generated emails at registration. Fails open.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
const PINGUTIL_LOOKUP_URL = 'https://pingutil.com/v1/lookup';
const PINGUTIL_CATEGORY_TTL = DAY_IN_SECONDS;
/**
* Screen one email address.
*
* @return array{block: bool, reason: ?string, degraded: ?string}
*/
function pingutil_screen_email( string $email ): array {
$result = array( 'block' => false, 'reason' => null, 'degraded' => null );
if ( ! defined( 'PINGUTIL_API_KEY' ) || '' === PINGUTIL_API_KEY ) {
return $result;
}
$at = strrpos( $email, '@' );
if ( false === $at ) {
return $result;
}
$host = strtolower( rtrim( substr( $email, $at + 1 ), '.' ) );
// Domain-level cache: only the category, never the verdict.
if ( 'disposable' === get_transient( 'pingutil_cat_' . md5( $host ) ) ) {
$result['block'] = true;
$result['reason'] = 'disposable_domain';
return $result;
}
// After a 429, skip the API until the backoff window ends.
if ( get_transient( 'pingutil_backoff' ) ) {
$result['degraded'] = 'backoff';
return $result;
}
$response = wp_remote_post(
PINGUTIL_LOOKUP_URL,
array(
'timeout' => 3,
'headers' => array(
'Authorization' => 'Bearer ' . PINGUTIL_API_KEY,
'Content-Type' => 'application/json',
),
'body' => wp_json_encode(
array(
'input' => $email,
'skip_enrichment' => true,
)
),
)
);
if ( is_wp_error( $response ) ) {
// Timeout, DNS failure, TLS error: fail open.
$result['degraded'] = $response->get_error_code();
return $result;
}
$code = (int) wp_remote_retrieve_response_code( $response );
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 429 === $code ) {
$wait = is_array( $data ) && isset( $data['retry_after_seconds'] )
? max( 1, (int) $data['retry_after_seconds'] )
: 60;
set_transient( 'pingutil_backoff', 1, $wait );
$result['degraded'] = 'rate_limited';
return $result;
}
if ( 200 !== $code || ! is_array( $data ) || ! isset( $data['verdict'] ) ) {
$result['degraded'] = 'http_' . $code; // 400, 401, 5xx: fail open.
return $result;
}
// Cache the category under the registrable domain the API returned.
if ( ! empty( $data['domain'] ) && ! empty( $data['category'] ) ) {
set_transient( 'pingutil_cat_' . md5( $data['domain'] ), $data['category'], PINGUTIL_CATEGORY_TTL );
}
$result['block'] = ! empty( $data['verdict']['is_spam'] );
$result['reason'] = $data['verdict']['reason'] ?? null;
return $result;
}
/**
* Add an error to $errors when the email should be rejected.
*/
function pingutil_add_registration_error( WP_Error $errors, string $email ): WP_Error {
// Core or WooCommerce already rejected the form (empty, invalid, taken email): skip the call.
if ( $errors->has_errors() || '' === $email ) {
return $errors;
}
$check = pingutil_screen_email( $email );
if ( $check['degraded'] ) {
error_log( 'pingutil registration check skipped: ' . $check['degraded'] );
}
if ( $check['block'] ) {
$errors->add(
'pingutil_blocked_email',
__( '<strong>Error:</strong> Please register with a permanent email address.' )
);
}
return $errors;
}
// wp-login.php?action=register (register_new_user()).
add_filter(
'registration_errors',
function ( $errors, $sanitized_user_login, $user_email ) {
return pingutil_add_registration_error( $errors, (string) $user_email );
},
10,
3
);
// WooCommerce My Account registration and account creation at checkout (wc_create_new_customer()).
add_filter(
'woocommerce_registration_errors',
function ( $errors, $username, $email ) {
return pingutil_add_registration_error( $errors, (string) $email );
},
10,
3
);
How the pieces fit
The hooks. registration_errors is applied inside register_new_user() with three arguments: the WP_Error collected so far, the sanitized username, and the email. If the returned object contains any error, WordPress aborts the registration and shows the messages on the form. WooCommerce doesn't go through register_new_user(). Its wc_create_new_customer() function applies woocommerce_registration_errors with $errors, $username, $email, and returns early if the WP_Error has an error code. Both filters need 10, 3 in add_filter so all three arguments arrive. WP_Error::has_errors() needs WordPress 5.1 or later.
Skipping when there are already errors. Core validates the email format and checks for duplicates before the filter runs. If any of that failed, there's no point spending an API call.
The timeout. wp_remote_post defaults to a 5-second timeout. Three is plenty for a lookup with skip_enrichment: true, which skips company enrichment and returns the verdict quickly. On timeout you get a WP_Error (code http_request_failed), and the function fails open.
429 handling. A pingutil key allows 60 requests per minute. Over that, the API answers 429 with a JSON body like { "error": "rate_limited", "retry_after_seconds": 60 }. Sleeping inside a registration request isn't an option, so the plugin stores a pingutil_backoff transient for that many seconds and lets registrations through until it expires. If you see this in your logs during normal traffic (not a bot flood), one free key is too small for your volume.
What gets cached, and what doesn't. A disposable domain is disposable for every address on it, so caching category per domain is safe and saves calls during a flood from one throwaway service. The verdict is per address: ccjxx90sze@gmail.com is spam and jane@gmail.com isn't, same domain. So the plugin never caches the verdict by domain. It writes the transient under the registrable domain the API returned (mail.yopmail.fr comes back as yopmail.fr) and reads under the host from the form. For most addresses those are the same string; for a subdomain the read simply misses and the API is called. The reasoning behind that key choice is in eTLD+1 explained, and TTL trade-offs are in caching domain lookups.
Low-trust addresses. The response also has verdict.low_trust for role and system addresses (info@, noreply@). The plugin lets those register. If you want to review them, store the reason in user meta from the user_register action instead of rejecting.
Things to know before you ship
- Privacy relays can be flagged. Apple Hide My Email and Firefox Relay addresses look random, and pingutil may mark some as
randomized_local_part. If your users skew toward iPhone owners, watch your logs for those blocks. See why relay addresses are not disposable email. - Multisite uses a different path. Network signups go through
wpmu_validate_user_signup, which returns an array with anerrorskey. The samepingutil_add_registration_error()function works there, but you have to wire that filter separately. - Users created by admins or the REST API skip these filters. They don't go through
register_new_user()orwc_create_new_customer(). That's usually fine, since those paths need an authenticated admin. - No bulk checks. There's no batch endpoint, so don't loop over your existing users with this function. It's built for live registrations.
Test it
- Turn on "Anyone can register" under Settings, General (or use the WooCommerce My Account form).
- Register with
test@mailinator.com. You should see the error. - Register with a random string at gmail.com, such as
ccjxx90sze@gmail.com. Also blocked. - Register with your own address. It should go through.
- Temporarily set
PINGUTIL_LOOKUP_URLto an unreachable URL such ashttps://10.255.255.1/, register again, and confirm the registration succeeds and a "skipped" line lands in your PHP error log (wp-content/debug.logifWP_DEBUG_LOGis on).
You can try addresses before writing any code in the live demo, and every response field is documented in the API reference.
Questions people ask
- Does the registration_errors filter run for WooCommerce registrations?
- No. WooCommerce creates customers through wc_create_new_customer(), which applies its own woocommerce_registration_errors filter instead. Hook both if your site uses the WordPress and the WooCommerce registration forms.
- Will an email check stop registration spam bots on its own?
- Not entirely. Bots that use real webmail addresses with plausible names will pass an email check. Pair it with a CAPTCHA or challenge on the form, and with email confirmation, and the check handles the throwaway and machine-generated addresses those miss.
- Where should I store the API key in WordPress?
- In wp-config.php as a constant, outside the web root if your host allows it. Don't put it in the options table, where any plugin with database access can read it, and never in JavaScript.