How to Verify Email Addresses on WordPress Forms (CF7, Gravity, WPForms)
WordPress doesn't verify email addresses. is_email() only checks that the input is shaped like one, and it accepts some malformed addresses. To catch dead domains, add a small plugin that looks up the domain's MX records with dns_get_record(), caches the answer in a transient, and hooks into each form's validation filter. Don't use checkdnsrr() alone: it reports success for domains that publish a null MX.
We tested the PHP below on PHP 8.5 on 6 October 2026, using is_email() and sanitize_email() from the current WordPress source. The hook names were checked against the WordPress, WooCommerce, Contact Form 7, Gravity Forms and WPForms documentation and source.
What is_email() actually checks
is_email() checks the shape of an address, loosely. It accepts some invalid addresses and rejects some valid ones.
The is_email reference lists what it tests: a minimum of 6 characters, an @ after the first character, allowed local-part characters, and a domain with at least two labels, no doubled periods and no misplaced hyphens. Its own docblock says it "does not grok i18n domains. Not RFC compliant."
We ran it against the cases from our regex test:
| Input | is_email() |
Correct answer |
|---|---|---|
jane+news@example.com, o'brien@example.ie |
Valid | Valid |
john..doe@example.com |
Valid | Invalid (doubled dot) |
.john@example.com |
Valid | Invalid (leading dot) |
| 65-character local part | Valid | Invalid (64-octet limit) |
user@münchen.de |
Invalid | Valid (IDN domain) |
jane@example.com (leading space) |
Invalid | Valid once trimmed |
jane@gmial.con |
Valid | Valid shape, but the domain doesn't exist |
PHP's built-in filter_var($email, FILTER_VALIDATE_EMAIL) rejected the doubled dot, the leading dot and the long local part correctly, but it also rejected münchen.de. Neither function looks at DNS.
The sanitize_email() trap
sanitize_email() deletes characters instead of rejecting the address, so sanitizing before validating can quietly store a different address.
| Input | sanitize_email() output |
|---|---|
user@münchen.de |
user@mnchen.de |
"john doe"@example.com |
johndoe@example.com |
john@-example.com |
john@example.com |
jane@[192.168.0.1] |
jane@192.168.0.1 |
用户@例子.广告 |
empty string |
Each of those outputs is a valid-looking address, and some of them could belong to someone else. Validate the trimmed input as typed. If sanitize_email() changes anything other than surrounding whitespace, show an error rather than saving the changed version. If you need internationalized domains, see internationalized email addresses.
The checkdnsrr() trap
checkdnsrr($domain, 'MX') returns true for a null MX record, which is a domain saying it never accepts mail.
RFC 7505 defines the null MX: a single MX record with preference 0 and a target of .. It's how a domain says "no mail here". Our results:
| Domain | checkdnsrr(..., 'MX') |
dns_get_record(..., DNS_MX) |
|---|---|---|
| gmail.com | true | five records |
| example.com | true | one record, priority 0, target "" |
| a domain that doesn't exist | false | empty array |
So dns_get_record() it is. PHP returns the null MX target as an empty string, which is what the plugin checks for.
The plugin
One helper that returns an error message or null, with DNS results cached for a day.
Save this as wp-content/plugins/email-domain-check/email-domain-check.php and activate it:
<?php
/**
* Plugin Name: Email Domain Check
* Description: Rejects form emails whose domain does not exist or does not accept mail.
*/
defined( 'ABSPATH' ) || exit;
/**
* Returns an error message, or null if the address is acceptable.
* DNS failures return null: a slow resolver is not evidence against the user.
*/
function edc_email_problem( string $email ): ?string {
$email = trim( $email );
if ( ! is_email( $email ) ) {
return __( 'Please enter a valid email address.', 'edc' );
}
$domain = strtolower( substr( strrchr( $email, '@' ), 1 ) );
$key = 'edc_mx_' . md5( $domain );
$cached = get_transient( $key );
if ( false !== $cached ) {
return 'ok' === $cached ? null : $cached;
}
$records = dns_get_record( $domain, DNS_MX );
if ( false === $records ) {
return null;
}
$problem = null;
if ( 1 === count( $records ) && in_array( $records[0]['target'], array( '', '.' ), true ) ) {
/* translators: %s: domain name */
$problem = sprintf( __( '%s does not accept email.', 'edc' ), $domain );
} elseif ( ! $records && ! checkdnsrr( $domain, 'A' ) && ! checkdnsrr( $domain, 'AAAA' ) ) {
/* translators: %s: domain name */
$problem = sprintf( __( '%s can\'t receive email. Check for a typo.', 'edc' ), $domain );
}
set_transient( $key, $problem ?? 'ok', DAY_IN_SECONDS );
return $problem;
}
With WordPress's functions stubbed, the helper returned null for jane@gmail.com, "example.com does not accept email." for jane@example.com, and "gmial.con can't receive email. Check for a typo." for jane@gmial.con. A domain with no MX record but an A or AAAA record passes, because RFC 5321 section 5.1 treats the address record as an implicit MX.
The transient keeps a busy form from repeating the same lookup. It caches the message, not just a flag, so a cached rejection shows the same text. If dns_get_record() fails outright it returns false, usually with a PHP warning in your log, and the helper lets the submission through.
Hooking it into each form
Every form plugin has its own validation filter. Add the ones you use to the same plugin file.
// Core registration (wp-login.php?action=register) and WooCommerce My Account registration.
$edc_registration = function ( $errors, $username, $email ) {
$problem = edc_email_problem( (string) $email );
if ( $problem ) {
$errors->add( 'edc_email', $problem );
}
return $errors;
};
add_filter( 'registration_errors', $edc_registration, 10, 3 );
add_filter( 'woocommerce_registration_errors', $edc_registration, 10, 3 );
// Contact Form 7: optional and required email fields.
foreach ( array( 'wpcf7_validate_email', 'wpcf7_validate_email*' ) as $edc_hook ) {
add_filter( $edc_hook, function ( $result, $tag ) {
$value = isset( $_POST[ $tag->name ] ) ? trim( wp_unslash( (string) $_POST[ $tag->name ] ) ) : '';
if ( '' !== $value ) {
$problem = edc_email_problem( $value );
if ( $problem ) {
$result->invalidate( $tag, $problem );
}
}
return $result;
}, 20, 2 );
}
// Gravity Forms: every email field on every form.
add_filter( 'gform_field_validation', function ( $result, $value, $form, $field ) {
if ( 'email' !== $field->type || ! $result['is_valid'] ) {
return $result;
}
$email = is_array( $value ) ? rgar( $value, 0 ) : (string) $value;
if ( '' !== $email ) {
$problem = edc_email_problem( $email );
if ( $problem ) {
$result['is_valid'] = false;
$result['message'] = $problem;
}
}
return $result;
}, 10, 4 );
// WPForms: every email field on every form.
add_action( 'wpforms_process', function ( $fields, $entry, $form_data ) {
foreach ( $fields as $id => $field ) {
if ( 'email' !== $field['type'] || '' === $field['value'] ) {
continue;
}
$problem = edc_email_problem( $field['value'] );
if ( $problem ) {
wpforms()->process->errors[ $form_data['id'] ][ $id ] = $problem;
}
}
}, 10, 3 );
Notes from each plugin's documentation:
registration_errorspasses aWP_Error, the sanitized login and the email. The callback must return$errorseven when there's no error. WooCommerce applieswoocommerce_registration_errorswith the same three arguments inwc_create_new_customer().- Contact Form 7 applies
wpcf7_validate_{type}, soemailandemail*(required) are separate filters. Priority 20 runs after CF7's own checks. CF7 itself callsis_email()through itswpcf7_is_email()wrapper. - Gravity Forms: when an email field has confirmation enabled,
$valueis an array, which is why the code readsrgar( $value, 0 ). Usegform_field_validation_{form}_{field}to target one field. - WPForms: writing to
wpforms()->process->errors[ $form_id ][ $field_id ]stops the submission and shows the message under that field.
Test on a staging site first. Form plugins change their internals between major versions, and these hooks run on every submission.
Going further than DNS
An MX check catches dead domains and typos. It can't tell whether the mailbox exists, and it doesn't stop bots.
- Mailbox checks need SMTP, which shared WordPress hosts rarely allow on port 25. If you call a verification API from
edc_email_problem(), usewp_remote_get()with'timeout' => 3, and returnnullwhen it times out. The fail-open reasoning is in real-time email verification on signup forms. - Bots should be stopped before the DNS lookup. Honeypot fields and the layers in how to stop fake signups cost real users nothing.
- Throwaway domains have working MX records. They need a list-plus-MX approach, covered in blocking disposable emails.
The takeaway
is_email()checks the shape, loosely. It isn't verification.- Never sanitize-then-validate.
sanitize_email()can turn a valid address into someone else's. - Use
dns_get_record(), notcheckdnsrr(), and treat an empty MX target as "no mail". - Cache lookups in a transient and fail open on DNS errors.
- To see the MX records for a domain your form rejected, use the MX lookup tool.
Common questions
Does WordPress verify email addresses?
Only the format. is_email() checks length, the @, allowed characters and the domain's shape. It doesn't look up DNS or contact the mail server, and its own documentation says it is not RFC compliant and does not handle internationalized domains. Real verification has to be added by a plugin or custom code.
Can I use checkdnsrr() to check an email domain in PHP?
Only with care. checkdnsrr($domain, 'MX') returned true for example.com in our test, even though example.com publishes a null MX record that says it never accepts mail. Use dns_get_record($domain, DNS_MX) and treat a single record whose target is empty as no mail.
Which hook do I use to validate email in Contact Form 7?
The wpcf7_validate_email and wpcf7_validate_email* filters. The asterisk version is for required email fields. The callback receives the validation result and the form tag, and calls $result->invalidate($tag, $message) to show an error under the field.
Should I run sanitize_email() before validating?
Validate the trimmed input first. sanitize_email() removes characters it doesn't like instead of rejecting the address. In our test it turned user@münchen.de into user@mnchen.de, a different domain, and "john doe"@example.com into johndoe@example.com. If sanitizing changes anything beyond whitespace, ask the user to re-enter the address.
Check your list before you send
Real mailbox checks over SMTP, and nothing is sent to the address. Sign in with Google and your first 100 email checks are free, no card. Plans for bigger lists are on the pricing page.