Block disposable emails in WordPress and WooCommerce with one plugin file

One free plugin file refuses throwaway emails at WordPress signup, both WooCommerce checkouts and Contact Form 7. It keeps relay addresses and needs no API key.

To block disposable emails in WordPress, add one plugin file that checks each new address where accounts and orders begin. That means the registration_errors filter, the WooCommerce account and checkout hooks, and Contact Form 7. The file below asks FFraud's free API about the domain once a day, with no key. It lets relay addresses such as Apple's Hide My Email through. When the API cannot answer, a daily list of 224,174 throwaway domains decides.

#Which hooks catch every signup?

WordPress and WooCommerce take a new email address in five places, and each has a hook that can stop it:

Where the address comes inHookHow the hook stops it
WordPress registration, wp-login.phpregistration_errorsAny error aborts the new account
WooCommerce My Account, and accounts made at either checkoutwoocommerce_registration_errorswc_create_new_customer() returns the error
WooCommerce classic checkout, guests toowoocommerce_after_checkout_validationThe error shows as a notice, and no order is made
WooCommerce checkout block, guests toowoocommerce_store_api_checkout_update_customer_from_requestA thrown RouteException answers HTTP 400 before the order exists
Contact Form 7wpcf7_validate_email and wpcf7_validate_email*The field shows the message

The checkout block places its order through WooCommerce's Store API, and WooCommerce's docs say the hooks of the classic checkout do not fire there. That is why snippets built on woocommerce_after_checkout_validation miss block checkouts.

We read WooCommerce 11.2.1 for the block checkout. Its Store API fires woocommerce_store_api_checkout_update_customer_from_request when the shopper presses Place order, before it creates the order. It catches a RouteException thrown there and sends the message back as the error. An account made at the block checkout goes through wc_create_new_customer() too, so the account filter covers it.

#What does the plugin look like?

Save it as wp-content/mu-plugins/block-disposable-emails.php. WordPress runs every must-use plugin on its own, and nobody can switch one off in wp-admin (WordPress docs). The same file works from wp-content/plugins after you activate it.

wp-content/mu-plugins/block-disposable-emails.php
<?php
/**
 * Plugin Name: Block disposable emails
 * Description: Refuses throwaway email addresses at WordPress registration, WooCommerce signup and checkout, and in Contact Form 7. FFraud's free API decides; its MIT list of disposable domains answers when the API cannot.
 * Version: 1.0.0
 * Requires PHP: 7.4
 * License: MIT
 */

defined( 'ABSPATH' ) || exit;

// Relay services such as Apple's Hide My Email forward to an inbox a real person reads. Set to false to refuse them too.
const FFRAUD_ACCEPT_RELAYS = true;
const FFRAUD_MESSAGE       = 'Please use an email address you will keep. Throwaway inboxes are not accepted.';
const FFRAUD_API_URL       = 'https://api.ffraud.com/public/email/check';
const FFRAUD_LIST_URL      = 'https://raw.githubusercontent.com/FFraud-com/disposable-email-domains/main/disposable-email-domains.txt';

/** The list lives in the uploads folder, which WordPress can write. */
function ffraud_list_file() {
	return wp_upload_dir( null, false )['basedir'] . '/disposable-email-domains.txt';
}

/** Download the list, clean it and swap it in. A failed or short download keeps the current file. */
function ffraud_refresh_list() {
	$response = wp_remote_get( FFRAUD_LIST_URL, array( 'timeout' => 30 ) );
	if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
		return 0;
	}
	$domains = array();
	foreach ( explode( "\n", wp_remote_retrieve_body( $response ) ) as $line ) {
		$domain = strtolower( rtrim( trim( $line ), '.' ) );
		if ( '' !== $domain && '#' !== $domain[0] ) {
			$domains[ $domain ] = true;
		}
	}
	$file = ffraud_list_file();
	if ( count( $domains ) < 100000 || ! file_put_contents( $file . '.tmp', implode( "\n", array_keys( $domains ) ) ) ) {
		return 0;
	}
	rename( $file . '.tmp', $file );
	return count( $domains );
}

/** True when the list holds the domain or a parent of it: inbox.mailinator.com matches mailinator.com. */
function ffraud_listed( $domain ) {
	static $domains = null;
	if ( null === $domains ) {
		$file    = ffraud_list_file();
		$domains = is_file( $file ) ? array_flip( file( $file, FILE_IGNORE_NEW_LINES ) ) : array();
	}
	$labels = explode( '.', $domain );
	for ( $i = 0; $i < count( $labels ) - 1; $i++ ) {
		if ( isset( $domains[ implode( '.', array_slice( $labels, $i ) ) ] ) ) {
			return true;
		}
	}
	return false;
}

/** 'temp', 'alias' or 'normal'. The API answers once a day per domain; without an answer, the list decides. */
function ffraud_domain_type( $domain ) {
	static $seen = array();
	if ( isset( $seen[ $domain ] ) ) {
		return $seen[ $domain ];
	}
	$key  = 'ffraud_type_' . md5( $domain );
	$type = get_transient( $key );
	if ( false === $type && ! get_transient( 'ffraud_api_down' ) ) {
		// The type depends on the domain alone, so the API gets user@ and the domain, never a real address.
		$response = wp_remote_post(
			FFRAUD_API_URL,
			array(
				'timeout' => 2,
				'headers' => array( 'Content-Type' => 'application/json' ),
				'body'    => wp_json_encode( array( 'email' => 'user@' . $domain ) ),
			)
		);
		$result = json_decode( wp_remote_retrieve_body( $response ), true );
		if ( 200 === wp_remote_retrieve_response_code( $response ) && isset( $result['email_type'] ) ) {
			$type = $result['email_type'];
			set_transient( $key, $type, DAY_IN_SECONDS );
		} else {
			// Skip the API for a minute, so an outage costs one wait a minute instead of one per signup.
			set_transient( 'ffraud_api_down', 1, MINUTE_IN_SECONDS );
		}
	}
	if ( false === $type ) {
		// The list cannot tell a relay from a throwaway inbox, so it refuses both.
		$type = ffraud_listed( $domain ) ? 'temp' : 'normal';
	}
	$seen[ $domain ] = $type;
	return $type;
}

function ffraud_refuses( $email ) {
	if ( ! is_email( $email ) ) {
		return false; // WordPress and WooCommerce report a malformed address themselves.
	}
	$type = ffraud_domain_type( strtolower( substr( strrchr( $email, '@' ), 1 ) ) );
	return 'temp' === $type || ( 'alias' === $type && ! FFRAUD_ACCEPT_RELAYS );
}

// WP-Cron downloads the list once a day.
add_action( 'ffraud_refresh_list', 'ffraud_refresh_list' );
add_action(
	'init',
	function () {
		if ( ! wp_next_scheduled( 'ffraud_refresh_list' ) ) {
			wp_schedule_event( time(), 'daily', 'ffraud_refresh_list' );
		}
	}
);
register_deactivation_hook( __FILE__, fn () => wp_clear_scheduled_hook( 'ffraud_refresh_list' ) );

// WordPress registration: wp-login.php?action=register.
add_filter(
	'registration_errors',
	function ( $errors, $login, $email ) {
		if ( ffraud_refuses( $email ) ) {
			$errors->add( 'disposable_email', '<strong>Error:</strong> ' . FFRAUD_MESSAGE );
		}
		return $errors;
	},
	10,
	3
);

// WooCommerce accounts: the My Account form, and accounts created at either checkout.
add_filter(
	'woocommerce_registration_errors',
	function ( $errors, $username, $email ) {
		if ( ffraud_refuses( $email ) ) {
			$errors->add( 'disposable_email', FFRAUD_MESSAGE );
		}
		return $errors;
	},
	10,
	3
);

// WooCommerce classic checkout, guests included.
add_action(
	'woocommerce_after_checkout_validation',
	function ( $data, $errors ) {
		if ( ffraud_refuses( $data['billing_email'] ?? '' ) ) {
			$errors->add( 'billing_email_disposable', FFRAUD_MESSAGE, array( 'id' => 'billing_email' ) );
		}
	},
	10,
	2
);

// WooCommerce block checkout, guests included. It runs when the shopper presses Place order.
add_action(
	'woocommerce_store_api_checkout_update_customer_from_request',
	function ( $customer, $request ) {
		if ( ffraud_refuses( $request['billing_address']['email'] ?? '' ) ) {
			throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException( 'disposable_email', FFRAUD_MESSAGE, 400 );
		}
	},
	10,
	2
);

// Contact Form 7: every email field, required or not.
foreach ( array( 'wpcf7_validate_email', 'wpcf7_validate_email*' ) as $ffraud_filter ) {
	add_filter(
		$ffraud_filter,
		function ( $result, $tag ) {
			$submission = WPCF7_Submission::get_instance();
			if ( $submission && ffraud_refuses( $submission->get_posted_string( $tag->name ) ) ) {
				$result->invalidate( $tag, FFRAUD_MESSAGE );
			}
			return $result;
		},
		20,
		2
	);
}
  • Only the domain leaves your site. The type of an address depends on its domain, so the API gets user@ and the domain. No customer address is sent.
  • One API call per domain a day. A transient keeps each answer for a day. The first address at a domain pays for the call, and the rest read the cache.
  • Relay addresses pass. Set FFRAUD_ACCEPT_RELAYS to false to refuse them as well.
  • A list for outages. WP-Cron downloads the list once a day into the uploads folder. A download under 100,000 domains is refused, and a new file replaces the old one with one rename().
  • A minute off after a failure. If the API fails or takes more than 2 seconds, the plugin stops asking it for a minute, and the list decides.

The list file sits in the public uploads folder, which is fine for a list that is public anyway. WordPress and WooCommerce check the format of an address themselves, so the plugin skips any address that fails is_email().

#Why let relay addresses through?

A relay address forwards to an inbox that a real person reads. FFraud's free list holds every relay domain we looked up, so a check by list alone turns these people away. The API tells them apart with email_type:

DomainServiceIn the free listemail_type
privaterelay.appleid.comHide My Email, through Sign in with AppleYesalias
duck.comDuckDuckGo Email ProtectionYesalias
mozmail.comFirefox RelayYesalias
simplelogin.comSimpleLoginYesalias
addy.ioaddy.ioYesalias
mailinator.comMailinator, a throwaway inboxYestemp

Answers of FFraud's free API on October 25, 2026, with the list read the same day. Apple creates a privaterelay.appleid.com address when someone picks Hide My Email in Sign in with Apple (Apple Support).

Our guide to email aliases and Hide My Email says most sites should accept relay addresses. At a checkout, the person behind one is a paying customer. The case for refusing them is one person opening many accounts, for a free trial again and again. If that costs you more, set FFRAUD_ACCEPT_RELAYS to false.

While the API is down, the list cannot tell a relay from a throwaway inbox. The plugin then refuses both until the API answers again.

#How long does a signup wait?

We timed the plugin on PHP 8.3 from a test machine in Germany. Each run was a fresh PHP process, the way each WordPress request is:

How long one signup waits for the email checkMilliseconds on PHP 8.3.25 without OPcache, October 25 to 26, 2026. The API call is the median of 12 checks with an empty cache.
  1. First address at a domain that day, one API call515 ms
  2. API down, first signup of the minute2,038 ms
  3. API down, later signups, the list from disk22 ms

Source: FFraud test of the plugin, October 25 to 26, 2026

Every later address at a domain reads the cached answer, with no call. The 12 calls took 399 to 1,018 ms, and your own time depends on your distance to the API. During an outage, the first signup of each minute waits out the 2-second timeout.

The list costs memory only when it loads. A request that loaded it peaked at 25.7 MB in our test, against 0.5 MB without it, WordPress itself not counted. The daily refresh peaked at 37.7 MB and took 0.54 to 0.65 seconds.

#How do you install and test it?

  1. Save the file in wp-content/mu-plugins, and make the folder if your site has none.
  2. Fetch the list now with WP-CLI: wp cron event run ffraud_refresh_list. Without WP-CLI, WP-Cron starts the download on a visit soon after.
  3. Sign up with test@mailinator.com. The form should show the message, and a real address should go through.
  4. Do the same at your checkout, as a guest and with a new account.

We ran the plugin on PHP 8.3 on October 25, 2026, with one API call a second at most:

test run, October 25, 2026, 23:59 UTC
PHP 8.3.25, opcache not loaded, 2026-10-25T23:59:55Z
== install: first request, then the WP-Cron run
scheduled: {"ffraud_refresh_list":{"timestamp":1792972795,"schedule":"daily"}}
after a second init: 1 event
refresh: 224174 domains, 3544411 bytes, 0.56 s
  (peak memory 37.7 MB)
== list guard: the download comes back short
short download: refresh returned 0, list file unchanged: yes
  (peak memory 0.7 MB)
== request 1, empty cache
  test@mailinator.com              temp   refused     780 ms  API calls: 1
  user@inbox.mailinator.com        temp   refused     376 ms  API calls: 1
  test@privaterelay.appleid.com    alias  accepted    681 ms  API calls: 1
  team@ffraud.com                  normal accepted    417 ms  API calls: 1
  team@ffraud.com                  normal accepted  0.015 ms  API calls: 0
  what the API received: {"email":"user@mailinator.com"} {"email":"user@inbox.mailinator.com"} {"email":"user@privaterelay.appleid.com"} {"email":"user@ffraud.com"}
  (peak memory 0.5 MB)
== request 2, the answers cached for a day
  test@mailinator.com              temp   refused   0.920 ms  API calls: 0
  test@privaterelay.appleid.com    alias  accepted  0.122 ms  API calls: 0
  team@ffraud.com                  normal accepted  0.120 ms  API calls: 0
  (peak memory 0.5 MB)
== the hooks
registration_errors, test@mailinator.com: ["<strong>Error:<\/strong> Please use an email address you will keep. Throwaway inboxes are not accepted."]
registration_errors, team@ffraud.com: []
woocommerce_registration_errors, test@mailinator.com: ["Please use an email address you will keep. Throwaway inboxes are not accepted."]
woocommerce_after_checkout_validation, test@mailinator.com: [["billing_email_disposable"],{"id":"billing_email"}]
woocommerce_after_checkout_validation, test@privaterelay.appleid.com: []
woocommerce_store_api_checkout_update_customer_from_request, test@mailinator.com: RouteException disposable_email, HTTP 400, "Please use an email address you will keep. Throwaway inboxes are not accepted."
woocommerce_store_api_checkout_update_customer_from_request, team@ffraud.com: passes
wpcf7_validate_email*, test@mailinator.com: {"your-email":{"reason":"Please use an email address you will keep. Throwaway inboxes are not accepted."}}
wpcf7_validate_email, team@ffraud.com: []
  (peak memory 0.5 MB)
== relays refused
1
with FFRAUD_ACCEPT_RELAYS false, woocommerce_after_checkout_validation, test@privaterelay.appleid.com: ["billing_email_disposable"]
  (peak memory 0.5 MB)
== API outage, empty cache: the request never gets an answer
  test@mailinator.com              temp   refused    2038 ms  API calls: 1
  test@privaterelay.appleid.com    temp   refused   0.892 ms  API calls: 0
  team@ffraud.com                  normal accepted  0.217 ms  API calls: 0
  what the API received: {"email":"user@mailinator.com"}
  (peak memory 25.7 MB)
== API outage, next request within the minute
  team@ffraud.com                  normal accepted     22 ms  API calls: 0
  (peak memory 25.7 MB)

WordPress and WooCommerce were not installed for this test. The plugin ran against our own stand-ins for the functions and classes it calls, with real requests to the API. Try it on a staging site before you ship it.

#Questions people ask

How do I block disposable email addresses in WordPress?

Save the plugin file above in wp-content/mu-plugins. It checks WordPress registration, WooCommerce accounts, both WooCommerce checkouts and Contact Form 7, and it needs no account or key.

What does WooCommerce check in an email address by itself?

The format, with WordPress's is_email(). When it creates an account, it also checks that no other account uses the address. A throwaway address passes both checks.

Should I also check the domain's MX records?

An MX check answers another question: whether the domain takes mail at all. Throwaway services do, so they pass it: on October 26, 2026, mailinator.com listed two MX records. The check does stop a domain like example.com, whose null MX says it accepts no mail (RFC 7505). The MX lookup shows any domain's records.

What replaces woocommerce_after_checkout_validation in the checkout block?

On the server, woocommerce_store_api_checkout_update_customer_from_request. It runs when the shopper presses Place order, and a RouteException thrown there stops the order with your message.

Will this block Apple's Hide My Email?

Not as shipped. The API marks relay addresses as alias, and the plugin lets them through unless you set FFRAUD_ACCEPT_RELAYS to false.

Can I block disposable emails in Contact Form 7?

Yes. The plugin adds a validation filter to every email field, required or not, and the field shows the message. Contact Form 7 runs these filters as wpcf7_validate_email and wpcf7_validate_email*.

Does the plugin send my customers' email addresses anywhere?

No. It sends user@ and the domain to FFraud's free API, once a day per domain. The full address stays on your site.

To check one address by hand, paste it into the free disposable email checker, or download the disposable email domains list. The API behind the plugin is described on the temporary email API page.

Sources

  1. registration_errors, WordPress Developer Resources
  2. Must Use Plugins, WordPress Advanced Administration Handbook
  3. wp_schedule_event(), WordPress Developer Resources
  4. set_transient(), WordPress Developer Resources
  5. Hook alternatives, WooCommerce developer docs
  6. Store API checkout route, WooCommerce 11.2.1, WooCommerce on GitHub
  7. wc_create_new_customer(), WooCommerce 11.2.1, WooCommerce on GitHub
  8. Custom validation, Contact Form 7 docs
  9. How to use Hide My Email with Sign in with Apple, Apple Support, December 4, 2025
  10. Email checks API, FFraud
  11. disposable-email-domains, 224,174 domains, FFraud, October 25, 2026
  12. RFC 7505: A "Null MX" No Service Resource Record for Domains That Accept No Mail, IETF, June 1, 2015