Hacking Contact Form 7: Mastering the `wpcf7_skip_mail` Hook
Contact Form 7 processes over 26 billion form submissions per year across its 5+ million active installations. Yet the plugin ships with almost no built-in spam filtering. No honeypots. No behavioral analysis. No rate limiting. It accepts whatever comes in and fires off an email.
That is not a criticism—it is a design philosophy. CF7 is deliberately minimal. It provides a hook-driven architecture and expects developers to extend it. The problem is that most developers never look beyond the plugin’s admin UI. They configure fields, set up a mail template, and move on—leaving the entire validation pipeline untouched.
This article walks through the wpcf7 hooks that let you intercept, inspect, and reject submissions at the server level. We will focus on wpcf7_skip_mail and the hooks surrounding it, because that is where you gain real control over what happens after a user hits submit.
The Problem: CF7 Trusts Everything
Out of the box, Contact Form 7 validates field types (required fields, email format, URL format) and then sends the email. That is the entire pipeline. There is no concept of “is this submission likely spam?” built into the core.
This matters because bots do not fail field validation. A bot submitting through the REST API or a headless browser will happily provide a valid name, a valid email, and a message body. Every required field is filled. Every format check passes. CF7 sees a perfectly valid submission and delivers it to your inbox.
The plugin’s architecture assumes that if you want additional validation—spam scoring, honeypot fields, token verification, behavioral analysis—you will hook into the submission lifecycle and add it yourself.
Most developers do not. Most developers install a CAPTCHA plugin and call it a day. The bots adapted to that years ago.
Technical Deep Dive: The CF7 Submission Lifecycle
Before writing any code, you need to understand the order in which CF7 processes a submission. The lifecycle looks like this:
- Form submission received — CF7 parses the POST data.
wpcf7_validatefilter — Field-level validation runs (required checks, format checks, custom validation filters per field tag).wpcf7_before_send_mailaction — Fires after validation passes but before mail is sent. You can modify submission data or perform additional checks here.wpcf7_skip_mailfilter — A boolean filter that determines whether CF7 should actually send the email. Returntrueto skip sending.- Mail is sent (unless skipped).
wpcf7_mail_sentaction — Fires after the email is successfully sent.wpcf7_mail_failedaction — Fires if the email fails to send.- Response returned to the browser.
The hooks at steps 2 through 4 are where you build server-side defenses. Each serves a different purpose:
wpcf7_validate — Field-Level Validation
This is the earliest point where you can reject a submission. It runs per-field and per-tag-type. You use it when your validation logic is tied to a specific form field.
add_filter( 'wpcf7_validate_text*', 'my_custom_name_validation', 20, 2 );
function my_custom_name_validation( $result, $tag ) {
if ( 'your-name' === $tag->name ) {
$value = isset( $_POST['your-name'] ) ? trim( $_POST['your-name'] ) : '';
// Reject submissions where the name contains a URL
if ( preg_match( '/https?:///i', $value ) ) {
$result->invalidate( $tag, 'Please enter a valid name.' );
}
}
return $result;
}
This filter fires for every text field of type text* (required text). The first argument is the validation result object, and the second is the tag object representing the form field. Calling $result->invalidate() marks the field as invalid and stops the submission.
The limitation is scope. This hook only sees one field at a time. If your validation logic needs to evaluate the entire submission—timing data, honeypot fields, cross-field analysis—you need a later hook.
wpcf7_before_send_mail — Full Submission Access
This action fires after all field validation has passed. You have access to the complete WPCF7_ContactForm object and all submitted data. This is where you perform checks that span the entire submission.
add_action( 'wpcf7_before_send_mail', 'my_submission_inspection', 10, 3 );
function my_submission_inspection( $contact_form, &$abort, $submission ) {
// Access submitted data
$posted_data = $submission->get_posted_data();
$name = isset( $posted_data['your-name'] ) ? $posted_data['your-name'] : '';
$email = isset( $posted_data['your-email'] ) ? $posted_data['your-email'] : '';
$message = isset( $posted_data['your-message'] ) ? $posted_data['your-message'] : '';
// Example: reject if message contains more than 3 URLs
$url_count = preg_match_all( '/https?://[^s]+/', $message );
if ( $url_count > 3 ) {
$abort = true;
$submission->set_status( 'spam' );
$submission->set_response(
__( 'Your message could not be sent.', 'my-plugin' )
);
}
}
Key detail: The $abort parameter is passed by reference. Setting it to true aborts the entire mail-sending process. Combined with set_status( 'spam' ), this cleanly rejects the submission and returns a configurable error message to the browser.
This hook is powerful, but it has one drawback: it operates as an action, not a filter. You cannot return a value to control flow. You modify state through the $abort reference and the submission object. For simpler “send or don’t send” logic, the next hook is cleaner.
wpcf7_skip_mail — The Kill Switch
This is the hook this article is named after, and it is the most surgical tool in the CF7 developer’s kit.
wpcf7_skip_mail is a boolean filter. It receives the current skip-mail state and the WPCF7_ContactForm object. Return true, and CF7 silently skips sending the email. The form still returns a “success” response to the browser by default—meaning the bot has no idea its submission was discarded.
add_filter( 'wpcf7_skip_mail', 'my_spam_gate', 10, 2 );
function my_spam_gate( $skip_mail, $contact_form ) {
$submission = WPCF7_Submission::get_instance();
if ( ! $submission ) {
return $skip_mail;
}
$posted_data = $submission->get_posted_data();
// --- Check 1: Honeypot field ---
// If a hidden field that humans never see has been filled, it is a bot.
$honeypot_value = isset( $posted_data['company-url'] ) ? $posted_data['company-url'] : '';
if ( ! empty( $honeypot_value ) ) {
return true; // Skip mail — bot detected
}
// --- Check 2: Timing analysis ---
// Humans take at least a few seconds to fill out a form.
// Bots submit within milliseconds.
$timestamp = isset( $posted_data['_form_timestamp'] ) ? (int) $posted_data['_form_timestamp'] : 0;
$elapsed = time() - $timestamp;
if ( $timestamp > 0 && $elapsed < 3 ) {
return true; // Submitted too fast — likely a bot
}
// --- Check 3: Keyword blacklist ---
$message = isset( $posted_data['your-message'] ) ? $posted_data['your-message'] : '';
$blacklist = array( 'buy backlinks', 'cheap seo', 'free audit', 'casino', 'viagra' );
foreach ( $blacklist as $keyword ) {
if ( false !== stripos( $message, $keyword ) ) {
return true; // Known spam phrase
}
}
return $skip_mail; // Pass through — not spam
}
There are several things worth noting about this pattern.
The bot receives a success response. This is critical. When you use wpcf7_skip_mail, CF7 does not return an error to the browser. It returns its standard success message. The bot’s operator sees a 200 response and a “thank you” message, and assumes the spam was delivered. They have no signal to adapt their approach. Compare this to returning a validation error, which tells the bot exactly what triggered the rejection.
You can layer multiple checks. The code above combines a honeypot, timing analysis, and keyword matching in a single filter. Each check is independent. If any one of them flags the submission, mail is skipped. You can add as many layers as you need—IP reputation, token verification, behavioral scoring—without touching the form template.
The filter is composable. Multiple plugins and functions can hook into wpcf7_skip_mail. If any of them returns true, mail is skipped. This means your custom logic coexists with other security plugins without conflict. You do not need to worry about hook priority races unless you are explicitly trying to override another filter.
Solution: A Complete Server-Side Validation Class
Here is a production-ready implementation that wraps the concepts above into a single class. It registers hooks for both wpcf7_before_send_mail (for logging) and wpcf7_skip_mail (for the actual spam gate).
<?php
/**
* CF7 Server-Side Spam Gate
*
* Drop this into your theme's functions.php or a custom plugin file.
*/
class CF7_Spam_Gate {
/**
* Minimum seconds a human needs to fill out the form.
*/
const MIN_SUBMIT_TIME = 3;
/**
* Maximum number of URLs allowed in the message body.
*/
const MAX_URLS = 2;
/**
* Initialize hooks.
*/
public static function init() {
add_filter( 'wpcf7_skip_mail', array( __CLASS__, 'evaluate_submission' ), 20, 2 );
add_action( 'wpcf7_before_send_mail', array( __CLASS__, 'log_submission' ), 10, 3 );
}
/**
* Evaluate the submission and decide whether to skip mail.
*
* @param bool $skip_mail Current skip-mail state.
* @param WPCF7_ContactForm $contact_form The form instance.
* @return bool
*/
public static function evaluate_submission( $skip_mail, $contact_form ) {
// If another filter already decided to skip, respect it.
if ( $skip_mail ) {
return $skip_mail;
}
$submission = WPCF7_Submission::get_instance();
if ( ! $submission ) {
return $skip_mail;
}
$posted_data = $submission->get_posted_data();
// Run checks. Each returns true if spam is detected.
$checks = array(
self::check_honeypot( $posted_data ),
self::check_timing( $posted_data ),
self::check_url_density( $posted_data ),
self::check_duplicate_submission( $posted_data ),
);
// If any check flags the submission, skip mail.
if ( in_array( true, $checks, true ) ) {
// Mark as spam in CF7's internal tracking.
$submission->set_status( 'spam' );
return true;
}
return $skip_mail;
}
/**
* Check if the honeypot field was filled.
*/
private static function check_honeypot( $data ) {
$honeypot = isset( $data['website-url'] ) ? trim( $data['website-url'] ) : '';
return ! empty( $honeypot );
}
/**
* Check if the form was submitted faster than humanly possible.
*/
private static function check_timing( $data ) {
$timestamp = isset( $data['_form_timestamp'] ) ? (int) $data['_form_timestamp'] : 0;
if ( 0 === $timestamp ) {
return false; // No timestamp — cannot evaluate.
}
$elapsed = time() - $timestamp;
return $elapsed < self::MIN_SUBMIT_TIME;
}
/**
* Check if the message body contains an excessive number of URLs.
*/
private static function check_url_density( $data ) {
$message = isset( $data['your-message'] ) ? $data['your-message'] : '';
$url_count = preg_match_all( '/https?://[^s]+/', $message );
return $url_count > self::MAX_URLS;
}
/**
* Check for duplicate submissions within a short window.
* Uses a transient keyed on a hash of the submission content.
*/
private static function check_duplicate_submission( $data ) {
$message = isset( $data['your-message'] ) ? $data['your-message'] : '';
$email = isset( $data['your-email'] ) ? $data['your-email'] : '';
$hash = md5( $email . $message );
$key = 'cf7_dup_' . $hash;
if ( get_transient( $key ) ) {
return true; // Same content was submitted recently.
}
// Store the hash for 10 minutes.
set_transient( $key, 1, 10 * MINUTE_IN_SECONDS );
return false;
}
/**
* Log submission metadata for debugging.
* Fires before mail is sent (or skipped).
*/
public static function log_submission( $contact_form, &$abort, $submission ) {
if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
return;
}
$posted_data = $submission->get_posted_data();
$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : 'unknown';
$timestamp = isset( $posted_data['_form_timestamp'] ) ? (int) $posted_data['_form_timestamp'] : 0;
$elapsed = $timestamp > 0 ? ( time() - $timestamp ) : 'N/A';
error_log( sprintf(
'[CF7 Spam Gate] Form ID: %d | IP: %s | Elapsed: %s sec | Status: %s',
$contact_form->id(),
$ip,
$elapsed,
$submission->get_status()
) );
}
}
// Boot the class.
add_action( 'init', array( 'CF7_Spam_Gate', 'init' ) );
Adding the Honeypot and Timestamp Fields to Your Form
The PHP above expects two hidden fields in your CF7 form template. Here is how to add them.
In your Contact Form 7 form editor, add the honeypot field and a container for the timestamp:
<div style="position:absolute;left:-9999px;" aria-hidden="true">
[text website-url autocomplete:off tabindex:-1]
</div>
[hidden _form_timestamp]
Then enqueue a small script to set the timestamp when the page loads:
document.addEventListener( 'DOMContentLoaded', function() {
var fields = document.querySelectorAll( 'input[name="_form_timestamp"]' );
var now = Math.floor( Date.now() / 1000 );
fields.forEach( function( field ) {
field.value = now;
});
});
The honeypot field is positioned off-screen. Sighted users never see it. Screen readers skip it because of aria-hidden="true". Bots, however, see an input field named website-url—which looks like a legitimate field they should fill in. When they do, check_honeypot() catches it.
The timestamp field records when the page was loaded. On submission, the server calculates the difference. Any submission arriving in under three seconds is almost certainly automated.
Why wpcf7_skip_mail Over wpcf7_validate
You might wonder why we use wpcf7_skip_mail for the spam gate instead of rejecting the submission outright at the wpcf7_validate stage. There are two reasons.
1. Information leakage. When you invalidate a field in wpcf7_validate, CF7 returns a validation error to the browser. That error message tells the bot exactly which check it failed. A bot operator can iterate against your validation logic until their payload passes. With wpcf7_skip_mail, the bot gets a success response. From its perspective, the spam was delivered. There is nothing to iterate against.
2. Separation of concerns. Field validation and spam detection are different problems. Field validation answers: “Is this data well-formed?” Spam detection answers: “Is this submission legitimate?” Mixing them in the same hook creates brittle code that is hard to maintain. Keeping spam logic in wpcf7_skip_mail lets you modify, enable, or disable it independently of your field validation rules.
Going Further: When DIY Is Not Enough
The class above handles basic spam patterns effectively. It will stop the majority of automated submissions from simple bots and form-marketing tools.
But it has blind spots. The honeypot field name is static—a bot that specifically targets your site can learn to leave it empty. The timing threshold is a fixed constant that does not account for network latency. The keyword blacklist requires manual maintenance. And there is no protection against bots running full headless browsers that execute JavaScript and fill the timestamp field honestly.
Modern bots are not dumb scripts firing curl requests. They are headless Chromium instances driven by Puppeteer or Playwright, executing your JavaScript, rendering your CSS, and interacting with the DOM exactly like a human browser. Against these, static honeypots and fixed thresholds are not enough.
This is where purpose-built plugins earn their value. Samurai Honeypot for Forms extends the concepts in this article with polymorphic field names (honeypot fields whose names and positions change on every page load), server-side token verification (stateless HMAC tokens that cannot be replayed), and multi-layer behavioral analysis—all running server-side, with zero external API calls and no cookies. It is the production-grade implementation of the patterns we have been building by hand.
If your spam volume is low and your threat model is simple, the custom class above will serve you well. If you are dealing with persistent, adaptive bots at scale, you will outgrow it.
Key Takeaways
- CF7 is a framework, not a complete solution. Its hook-driven architecture expects you to add validation logic through
wpcf7_validate,wpcf7_before_send_mail, andwpcf7_skip_mail. wpcf7_skip_mailis the preferred hook for spam detection because it silently discards spam without leaking information back to the bot.- Layer your defenses. Combine honeypots, timing analysis, content inspection, and duplicate detection. No single check catches everything.
- Return success to bots. Never tell an attacker that their submission was rejected. A “thank you” message is the best deception you have.
- Static defenses have a shelf life. Hardcoded field names, fixed thresholds, and keyword lists require ongoing maintenance—or a plugin that handles the evolution for you.