WordPress Form Validation: Protecting Signups Without Plugins
Your Plugin Tab Has 47 Active Plugins. You Don’t Need a 48th.
The average WordPress site runs 20+ plugins. Many run far more. Every one adds weight: database queries, script loads, update nags, compatibility risks. And when a “simple email validation” plugin loads jQuery UI, a custom CSS framework, and three admin pages just to check if an email address is real? That’s not validation. That’s bloat.
WordPress’s plugin ecosystem solves everything. It also oversolves everything. A form validation plugin that ships 200KB of minified JavaScript to do what 15 lines of vanilla code can handle isn’t a solution. It’s technical debt wearing a friendly dashboard.
Here’s the alternative. Validate emails on your WordPress forms with raw PHP, vanilla JavaScript, and one API call. No plugin dependency. No update anxiety. No wondering whether version 4.2.1 broke your checkout flow.
What WordPress Gives You by Default
WordPress has exactly one email validation function: is_email(). It lives in wp-includes/formatting.php and it’s been there since WordPress 0.71.
// WordPress core: is_email()
$valid = is_email('[email protected]'); // Returns the email or false
The function checks minimum length (at least 6 chars), splits on @, validates local part characters against a limited allowlist, and checks the domain portion for basic structure (no consecutive periods, valid subdomain characters, at least two labels). No DNS lookup. No MX check. No disposable detection. It’s also not RFC 5322 compliant and doesn’t support internationalized domains.
It’ll happily validate [email protected]. That address passes is_email() but bounces on delivery. Every single time.
For registration forms, WordPress also runs sanitize_email() before saving. But sanitization isn’t validation. It strips disallowed characters silently, collapses consecutive periods, and returns whatever survives. user@@bad..email becomes [email protected] because the second @ lands in the domain part (split with a limit of 2) and gets stripped as an invalid subdomain character. The result passes format checks but probably isn’t deliverable.
How many garbage signups are sitting in your wp_users table right now?
Custom REST API Endpoint for Email Validation
WordPress REST API gives you a clean way to expose a validation endpoint that your frontend JavaScript can call. No page reload required.
// functions.php or a custom mu-plugin
add_action('rest_api_init', function () {
register_rest_route('custom/v1', '/validate-email', [
'methods' => 'POST',
'callback' => 'handle_email_validation',
'permission_callback' => '__return_true',
'args' => [
'email' => [
'required' => true,
'sanitize_callback' => 'sanitize_email',
],
],
]);
});
function handle_email_validation(WP_REST_Request $request) {
$email = $request->get_param('email');
if (!is_email($email)) {
return new WP_REST_Response([
'valid' => false,
'reason' => 'Invalid email format.',
], 200);
}
$domain = substr(strrchr($email, '@'), 1);
// MX record check
if (!checkdnsrr($domain, 'MX')) {
return new WP_REST_Response([
'valid' => false,
'reason' => 'This domain does not accept email.',
], 200);
}
// Disposable domain check
$disposable = [
'mailinator.com', 'guerrillamail.com', 'tempmail.com',
'throwaway.email', 'yopmail.com', 'sharklasers.com',
'grr.la', 'dispostable.com', 'trashmail.com',
];
if (in_array(strtolower($domain), $disposable, true)) {
return new WP_REST_Response([
'valid' => false,
'reason' => 'Disposable email addresses are not allowed.',
], 200);
}
return new WP_REST_Response(['valid' => true], 200);
}
That’s a full validation endpoint in under 50 lines. It checks format, DNS, and disposable domains. No plugin. No composer dependency. Just PHP functions that ship with WordPress and your server’s DNS resolver.
The permission_callback is set to __return_true because this is a public endpoint. Registration forms need it before the user is authenticated. If you’re worried about abuse, add rate limiting with a transient:
function handle_email_validation(WP_REST_Request $request) {
$ip = $_SERVER['REMOTE_ADDR'];
$transient_key = 'email_check_' . md5($ip);
$count = (int) get_transient($transient_key);
if ($count >= 10) {
return new WP_REST_Response([
'valid' => false,
'reason' => 'Too many requests. Try again later.',
], 429);
}
set_transient($transient_key, $count + 1, 60);
// ... rest of validation logic
}
Ten checks per minute per IP. Enough for legitimate users, tight enough to discourage scrapers.
JavaScript Validation on Blur
Server-side catches problems after submission. Client-side catches them while the user is still filling out the form. Better experience. Fewer round trips.
document.addEventListener('DOMContentLoaded', function () {
var emailField = document.querySelector('#user_email, #billing_email, input[type="email"]');
if (!emailField) return;
var typoMap = {
'gmial.com': 'gmail.com',
'gmal.com': 'gmail.com',
'gamil.com': 'gmail.com',
'gnail.com': 'gmail.com',
'yaho.com': 'yahoo.com',
'yahooo.com': 'yahoo.com',
'hotmal.com': 'hotmail.com',
'hotmial.com': 'hotmail.com',
'outlok.com': 'outlook.com',
'outllook.com': 'outlook.com'
};
emailField.addEventListener('blur', function () {
var email = this.value.trim().toLowerCase();
if (!email || email.indexOf('@') === -1) return;
var domain = email.split('@')[1];
// Typo correction first (no network call needed)
if (typoMap[domain]) {
showMessage(this, 'Did you mean ' + email.replace(domain, typoMap[domain]) + '?', 'warning');
return;
}
// API validation
fetch('/wp-json/custom/v1/validate-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email })
})
.then(function (res) { return res.json(); })
.then(function (data) {
if (!data.valid) {
showMessage(emailField, data.reason, 'error');
} else {
clearMessage(emailField);
}
})
.catch(function () {
// API failed, don't block the user
clearMessage(emailField);
});
});
function showMessage(field, text, type) {
clearMessage(field);
var el = document.createElement('div');
el.className = 'email-validation-msg';
el.style.fontSize = '13px';
el.style.marginTop = '4px';
el.style.color = type === 'error' ? '#dc3232' : '#dba617';
el.textContent = text;
field.parentNode.insertBefore(el, field.nextSibling);
}
function clearMessage(field) {
var existing = field.parentNode.querySelector('.email-validation-msg');
if (existing) existing.remove();
}
});
Zero dependencies. No jQuery. Vanilla JavaScript that works in every browser shipping today.
The blur event fires when the user tabs or clicks away from the email field. That’s the right moment. Don’t validate on every keystroke. That hammers your endpoint and annoys users who are still typing.
The .catch() block is critical. If the REST endpoint is down or slow, the form still works. Never let validation become a blocker.
Server-Side Validation in functions.php
Client-side validation improves UX. Server-side validation enforces rules. You need both. A user can disable JavaScript or bypass your frontend entirely.
For WordPress registration:
add_filter('registration_errors', 'validate_registration_email', 10, 3);
function validate_registration_email($errors, $sanitized_user_login, $user_email) {
$domain = strtolower(substr(strrchr($user_email, '@'), 1));
if (!checkdnsrr($domain, 'MX')) {
$errors->add(
'invalid_email_domain',
'<strong>Error:</strong> This email domain doesn\'t accept mail. Please use a different address.'
);
}
return $errors;
}
The registration_errors filter fires during wp-login.php?action=register. It gives you the errors object, the username, and the email. Add your error and WordPress handles the rest (displays it on the form, blocks the registration).
Why checkdnsrr() over getmxrr()? Both check MX records. checkdnsrr() returns a boolean, which is all you need here. getmxrr() populates an array with the full record set and weights, useful if you’re building something more complex but unnecessary for a pass/fail check.
One caveat: per RFC 5321, when a domain has no MX records, the A record can serve as a mail server fallback. So a missing MX doesn’t guarantee the domain can’t receive email. In practice, legitimate domains almost always publish MX records. The ones that don’t are nearly always fake or abandoned.
MX-only validation catches roughly 8-12% of fake signups on typical WordPress sites. Not perfect, but it’s free and adds under 50ms of latency.
Adding API-Powered Validation with wp_remote_post
MX checks catch dead domains. They don’t catch disposable addresses with valid MX records, catch-all domains that accept everything, or mailboxes that existed last month but don’t anymore. For that, you need an email validation API.
WordPress has wp_remote_post() built in. No cURL wrappers needed.
function truemail_check_email($email) {
$api_key = defined('TRUEMAIL_API_KEY') ? TRUEMAIL_API_KEY : '';
if (empty($api_key)) {
return ['valid' => true]; // No API key, skip check
}
$response = wp_remote_post('https://api.truemail.io/v1/verify', [
'headers' => [
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode([
'email' => $email,
'checks' => ['syntax', 'mx', 'disposable'],
]),
'timeout' => 3,
]);
if (is_wp_error($response)) {
return ['valid' => true]; // Fail open
}
$body = json_decode(wp_remote_retrieve_body($response), true);
return [
'valid' => ($body['status'] ?? '') === 'deliverable',
'status' => $body['status'] ?? 'unknown',
'disposable' => $body['disposable'] ?? false,
'catch_all' => $body['catch_all'] ?? false,
];
}
Store the API key in wp-config.php as a constant, not in the database:
// wp-config.php
define('TRUEMAIL_API_KEY', 'your-api-key-here');
Constants don’t show up in the admin UI. They can’t be changed through a compromised plugin. And they’re available before WordPress fully boots, which matters if you’re doing early validation.
The 3-second timeout is intentional. Registration forms have roughly a 2-3 second latency budget before users bounce. Syntax + MX checks through the API typically return in 100-400ms. Full SMTP verification can take 500-3,000ms. Keep the synchronous path fast and queue the deep verification for later.
Now wire the API check into registration:
add_filter('registration_errors', 'api_validate_registration_email', 20, 3);
function api_validate_registration_email($errors, $sanitized_user_login, $user_email) {
// Don't stack errors - skip if basic validation already failed
if ($errors->has_errors()) {
return $errors;
}
$result = truemail_check_email($user_email);
if ($result['disposable']) {
$errors->add(
'disposable_email',
'<strong>Error:</strong> Disposable email addresses aren\'t allowed. Please use your regular email.'
);
} elseif ($result['status'] === 'undeliverable') {
$errors->add(
'undeliverable_email',
'<strong>Error:</strong> This email doesn\'t appear to be deliverable. Please check the address.'
);
}
return $errors;
}
Priority 20 means this runs after the default WordPress validation (priority 10) and after our MX check (priority 10). If earlier checks already flagged the email, we skip the API call entirely. Don’t burn API credits on addresses that already failed.
WooCommerce Checkout Hooks
WooCommerce has its own validation hooks. The registration_errors filter doesn’t fire at checkout.
add_action('woocommerce_after_checkout_validation', 'validate_checkout_email_api', 10, 2);
function validate_checkout_email_api($data, $errors) {
$email = $data['billing_email'] ?? '';
if (empty($email) || $errors->get_error_messages()) {
return;
}
$result = truemail_check_email($email);
if ($result['disposable']) {
$errors->add(
'validation',
'Please use a permanent email address so we can send your order confirmation.'
);
} elseif ($result['status'] === 'undeliverable') {
$errors->add(
'validation',
'This email doesn\'t appear to be deliverable. Please double-check your address.'
);
}
}
The woocommerce_after_checkout_validation hook passes the form data directly. No need to read from $_POST. Cleaner and more testable.
Contact Form 7 and Gravity Forms Hooks
Both major form plugins expose validation filters. You can hook in without modifying the plugin code.
Contact Form 7:
add_filter('wpcf7_validate_email*', 'cf7_validate_email_field', 20, 2);
add_filter('wpcf7_validate_email', 'cf7_validate_email_field', 20, 2);
function cf7_validate_email_field($result, $tag) {
$email = isset($_POST[$tag->name]) ? trim($_POST[$tag->name]) : '';
if (empty($email)) {
return $result;
}
$check = truemail_check_email($email);
if ($check['disposable']) {
$result->invalidate($tag, 'Please use a permanent email address.');
}
return $result;
}
Gravity Forms:
add_filter('gform_field_validation', 'gf_validate_email_field', 10, 4);
function gf_validate_email_field($result, $value, $form, $field) {
if ($field->type !== 'email' || !$result['is_valid']) {
return $result;
}
$email = is_array($value) ? $value[0] : $value;
$check = truemail_check_email($email);
if ($check['disposable']) {
$result['is_valid'] = false;
$result['message'] = 'Disposable email addresses aren\'t accepted.';
}
return $result;
}
Same truemail_check_email() function from earlier. Write it once, hook it everywhere. That’s the advantage of not using a plugin. Your validation logic lives in one place, and you wire it into whatever form system your site uses.
Why Most WordPress Validation Plugins Are Bloated
I audited five popular email validation plugins from the WordPress plugin directory. The results aren’t pretty.
One plugin loaded 180KB of JavaScript on every page (not just pages with forms). Another registered 14 database options and two custom tables for what amounted to a regex check and an API call. A third one fired an external request on every admin page load to check for license status.
The disposable email blocklist that most plugins ship is static. Baked into the plugin files. Updated when the developer pushes a new version. New disposable providers launch weekly. Between updates, they slip through. An API-backed check hits a list of 180,000+ domains that updates daily.
Compare that to the approach in this post. One function for API calls. A few filters hooked into the right places. A JavaScript file that loads only on pages with email fields. Total footprint: under 5KB of PHP and 2KB of JavaScript.
Every plugin you remove is one fewer thing that can break on the next WordPress core update.
Caching Results with Transients
Don’t hit the API twice for the same email. WordPress transients handle short-term caching without a separate caching layer.
function truemail_check_email_cached($email) {
$email = strtolower(trim($email));
$cache_key = 'tm_' . md5($email);
$cached = get_transient($cache_key);
if ($cached !== false) {
return $cached;
}
$result = truemail_check_email($email);
$ttl = ($result['valid']) ? DAY_IN_SECONDS : HOUR_IN_SECONDS;
set_transient($cache_key, $result, $ttl);
return $result;
}
Deliverable addresses get cached for 24 hours. Failed checks get 1 hour (so you retry sooner in case of transient API issues). On forms where users average 1.8 submissions per signup (correcting password fields, fixing name typos), caching cuts your API calls nearly in half.
If you’re running Redis or Memcached via an object cache plugin, transients automatically use that backend. No code changes needed.
The Lean Stack
Here’s what the full setup looks like. No plugins.
Server-side: registration_errors filter with MX check and API validation. woocommerce_after_checkout_validation for WooCommerce. CF7 and Gravity Forms filters if you use them. Transient caching to reduce API calls.
Client-side: vanilla JavaScript on blur. Typo correction map for the top 10 misspelled domains. Fetch call to your custom REST endpoint. Graceful fallback when the endpoint is slow.
Total files touched: functions.php (or a custom mu-plugin) and one .js file enqueued on form pages. That’s it.
No admin settings page. No database migrations. No license key activation. No “premium tier” upsell banner in your dashboard.
What would you rather maintain: 50 lines of code you wrote and understand, or a plugin that auto-updates and could change behavior any Tuesday?
Keep it lean. Keep it yours.