Skip to content
  • FG-72
  • 14 min read
  • Last checked November 8, 2026

How to Stop Contact Form Spam on a WordPress Site (Without Blocking Real Visitors)

Your contact form fills your inbox with junk. Find how the spam arrives, then layer honeypots, Turnstile or CAPTCHA, Akismet, validation and rate limits.

Run the health check

Your contact form used to bring you leads. Now it brings SEO offers, crypto links, fake invoices and messages in languages your customers do not speak, sometimes dozens a day. Real inquiries get buried.

Form spam is not a sign that your site was hacked. Every public form on the internet is found and tested by automated scripts, and some of it is typed in by people paid to do so. No single trick stops all of it. What works is a stack of cheap, independent checks, each catching a different kind of sender, plus a way to measure the result. This guide is for anyone running Contact Form 7, WPForms, Gravity Forms, Fluent Forms, Formidable, Ninja Forms or a page builder form. Spam user accounts and comment spam are a different door and are covered in the separate guide on fake registrations and comment spam.

What is happening

Bots find forms by crawling links and by searching for footprints. A footprint is HTML that reveals which plugin built the form, such as a script path under /wp-content/plugins/ or a known hidden field. Once a bot knows the plugin, it knows the exact field names and the exact URL the form submits to.

That last part matters. Most WordPress forms do not submit to the page you see. They send an AJAX request to /wp-admin/admin-ajax.php or to a REST API route under /wp-json/. Contact Form 7, for example, posts to /wp-json/contact-form-7/v1/contact-forms/FORM_ID/feedback. A bot does not need to load your page, run your JavaScript or see your CAPTCHA. It can send the POST request directly, thousands of times, from many IP addresses.

Sender How it behaves What usually stops it
Simple script Posts straight to the endpoint, fills every field it finds, never runs JavaScript Honeypot field, server-side token check, rate limit
Form-aware bot Knows the plugin, skips known honeypot names, submits instantly Time-based check, Turnstile or CAPTCHA verified on the server
Headless browser Runs a real browser engine, loads the page and executes scripts Turnstile, reCAPTCHA or hCaptcha risk scoring, rate limit
CAPTCHA-solving service Bot forwards the challenge to people or a solver and gets a valid token back Content filtering (Akismet), validation rules, rate limit
Human spammer Types a message by hand, often the same pitch on thousands of sites Content filtering, disallowed words, field rules, form design

Each row needs a different tool, which is why one plugin setting rarely fixes the problem. The steps below go from cheapest and most invisible to strictest.

Before you start

You need a WordPress administrator login, SSH or your hosting panel log viewer, and for rate limits either Cloudflare or control of the web server configuration. Back up any config file before you edit it.

How to run commands: connect to your server with SSH and change into the site folder, the one that contains wp-config.php. Some hosts install WP-CLI as wp, others as wp-cli; use whichever responds when you type it with --info. The curl examples run on your own computer: macOS and Linux have it built in, and on Windows you can use WSL or the curl.exe that ships with Windows 10 and later. The diagnostic tools work from a browser.

Step 1: Find out how the spam reaches your form

Find the submit endpoint in the browser

Open your form page in Chrome, Edge or Firefox, press F12 and open the Network tab. Fill the form with test data and submit it. Click the request that fires on submit (usually admin-ajax.php, feedback or the page URL) and open the Payload panel. For admin-ajax forms you will see a field named action whose value identifies the form plugin. Note both.

Count who is posting to it

Run this on the server over SSH, from any folder:

grep "POST /wp-admin/admin-ajax.php" ACCESS_LOG | awk '{print $1}' | sort | uniq -c | sort -rn | head -20

ACCESS_LOG is the path to your web server access log. Your hosting panel usually shows it under Logs; on a server you manage it is often /var/log/nginx/access.log or /var/log/apache2/access.log. If your form uses a REST route, replace the quoted text with that path, for example "POST /wp-json/contact-form-7/". awk '{print $1}' prints the first column, which is the client IP in the default log format. uniq -c counts repeats and sort -rn puts the busiest IP at the top.

Good output is a short list where no address has more than a handful of posts per day. Bad output is a few addresses with hundreds of posts, or a long tail of addresses each posting once or twice, which means a botnet. If every line shows Cloudflare addresses, your server is not restoring the real visitor IP; ask your host to fix that, because rate limits and Akismet depend on it.

Check whether the sender ever loaded the page

grep "IP_ADDRESS" ACCESS_LOG | head -30

Replace IP_ADDRESS with one of the busy addresses from the previous command. A real visitor shows a GET for the page, then CSS and JavaScript, then the POST. A direct-post bot shows only POST lines, so browser-only checks cannot stop it.

Step 2: Switch on the invisible checks your form plugin already has

Most form plugins ship with free, invisible defenses that cost real visitors nothing. Turn these on first.

Honeypot fields

A honeypot is an extra field hidden from people with CSS and labeled so screen readers skip it. Simple bots fill every field, so a submission with a value there is rejected. WPForms, Gravity Forms, Fluent Forms, Formidable and Ninja Forms include one in their settings; Contact Form 7 relies on add-ons.

A bot written for a popular plugin already knows which field to leave blank, so treat a honeypot as a volume filter, not a lock.

Time-based and token checks

Some plugins add a hidden token or timestamp when the page loads and reject submissions that arrive too fast, too late, or without the token. They work well against direct-post bots, but a cached page can carry an old token. If real visitors then see “spam” errors, the guide on forms that submit but never arrive covers stale tokens.

Submit the form yourself to confirm it still arrives, then compare the plugin spam count over the next day with Step 1.

Step 3: Add a challenge that is verified on the server

If spam continues, add a bot challenge. The three common choices behave differently for privacy and accessibility, so pick on purpose.

Service What the visitor sees Trade-offs
Cloudflare Turnstile Usually nothing or a brief spinner; a checkbox only when signals look risky. No image puzzles. Free and works without moving your site to Cloudflare. Loads a script from Cloudflare, so mention it in your privacy policy.
Google reCAPTCHA v2 An “I am not a robot” checkbox, sometimes an image puzzle. The invisible variant only shows a puzzle when suspicious. Image puzzles are hard for people with low vision or motor impairments. Sends visitor data to Google.
Google reCAPTCHA v3 Nothing. Returns a score from 0.0 (likely bot) to 1.0 (likely human). No puzzle, but you choose a threshold (Google suggests starting at 0.5). People on VPNs can score low and be rejected silently.
hCaptcha A checkbox and sometimes an image puzzle; paid tiers offer passive modes. Positioned as more privacy-focused than Google, but puzzles still create friction.

In the EU and UK these third-party scripts can raise consent questions. Disclose them in your privacy policy, and check that your cookie banner does not block the challenge script and silently break the form.

Why the server check is the part that matters

The widget in the browser only produces a token. The protection happens when your server sends that token to the provider and gets a yes or no back. For Turnstile that call goes to https://challenges.cloudflare.com/turnstile/v0/siteverify. Cloudflare documents that each token is valid for 300 seconds and can be validated only once. If your form plugin or theme only shows the widget but never calls the verify endpoint, a bot that posts directly to the endpoint without any token goes straight through.

Prefer the form plugin’s native integration (Contact Form 7, WPForms, Gravity Forms and most others include Turnstile and reCAPTCHA settings) because it verifies the token in the same request that saves the entry.

Prove the server rejects a missing token

Run this on your own computer for a Contact Form 7 form. It posts without a token, the way a bot would:

curl -s -X POST "https://example.com/wp-json/contact-form-7/v1/contact-forms/FORM_ID/feedback" \
  -F "_wpcf7_unit_tag=wpcf7-fFORM_ID-o1" \
  -F "your-name=Test" -F "your-email=you@example.com" -F "your-message=token test"

example.com is your domain. FORM_ID is the number in the form shortcode, for example the id attribute in the Contact Form 7 shortcode. -s hides the progress meter, -X POST sends a POST, each -F adds a form field, and the backslashes continue the command on the next line. Match the field names to your Network tab payload.

Good output is JSON with "status":"spam" or "status":"validation_failed", meaning the server refused a submission with no challenge token. Bad output is "status":"mail_sent", which means the challenge is decoration only and bots can bypass it. For other plugins, copy the request from the Network tab with right click, Copy, Copy as cURL, delete the token field and resend it.

Step 4: Filter the content with Akismet

Challenges stop automation. They do nothing against a person or a solving service that passes the challenge. Akismet looks at the content, the sender details and its network-wide history of known spam, and classifies the submission.

Contact Form 7 uses it when you add tag options such as akismet:author on the name field and akismet:author_email on the email field. Most other form plugins have an Akismet toggle once the Akismet plugin is connected. Without that wiring, Akismet only checks comments.

Akismet free Personal keys are only for non-commercial sites. A business site, a store or a site that runs ads needs a paid plan, and using a personal key on a commercial site can get the key restricted.

Entries flagged by Akismet are usually marked as spam rather than deleted. Review that folder for a few weeks to catch false positives.

Step 5: Tighten server-side validation

Every rule that runs on the server is a rule a bot cannot skip. Rules in the browser, such as the HTML required attribute or a JavaScript check, are skipped by anything that posts directly. Use the validation features in your form plugin, which run in PHP:

  • Make the fields you truly need required, and validate the email field as an email.
  • Set maximum lengths. A name field never needs 500 characters.
  • Reject links in fields that should never hold one, such as name, company and phone. Many plugins let you block URLs or specific words per field.
  • Add obvious spam phrases to a blocklist if your plugin offers one. WordPress core keys under Settings, Discussion only apply to comments, not to form plugins.

Stop your form from being a spam relay

If your form sends the visitor a confirmation email that includes their message, a spammer can type a victim address into the email field and their advert into the message. Your server then delivers the spam, signed by your domain. Keep confirmation emails short and generic, never echo the message body, and do not send a confirmation at all for submissions marked as spam.

Step 6: Rate limit the form endpoint

A rate limit caps submissions per IP address per time window. It does nothing for a slow trickle, but it stops floods. Bots slowing the whole site are covered in the CPU spikes from bots guide.

With Cloudflare

In the Cloudflare dashboard, find the WAF rate limiting rules for your domain (menu names change; search for “rate limiting rules”). Create a rule that matches the URI path of your endpoint, for example equals /wp-admin/admin-ajax.php or starts with /wp-json/contact-form-7/. Cloudflare documents that the Free plan includes one rate limiting rule, can match only on the path, and counts per IP over a 10 second period. Higher plans add more match fields (the request method needs Business or above), longer windows and more rules.

Start loose and watch Security events. admin-ajax.php also serves features such as WooCommerce cart fragments and live search, so a tight limit there can break them; a REST route used only by the form is safer.

With Nginx

This is a server config file you edit, usually the site file under /etc/nginx/sites-available/ or a host-provided include. It limits only POST requests:

map $request_method $FORM_LIMIT_KEY {
    POST    $binary_remote_addr;
    default "";
}
limit_req_zone $FORM_LIMIT_KEY zone=FORM_ZONE:10m rate=5r/m;

location = /wp-admin/admin-ajax.php {
    limit_req zone=FORM_ZONE burst=5 nodelay;
    limit_req_status 429;
    # copy the PHP lines from your existing location ~ \.php$ block here
}

FORM_LIMIT_KEY and FORM_ZONE are names you choose; keep them identical in each place. The map and limit_req_zone lines belong in the http context, outside any server block. Nginx does not count requests whose key is empty, so GET requests pass untouched. rate=5r/m allows five requests per minute per IP, burst=5 allows a short queue above that, nodelay answers immediately instead of slowing requests, and limit_req_status 429 returns “Too Many Requests” instead of the default 503. The PHP handling lines (usually an include of fastcgi parameters and a fastcgi_pass) must be copied in, or PHP will not run for that URL.

Run nginx -t before reloading: good output ends with “test is successful”, bad output names the broken line. On shared hosting, ask your host or use Cloudflare.

Step 7: Block or challenge by country only with care

If all the spam comes from countries you never sell to, a geographic rule can cut volume. It also catches real customers who travel or use a VPN, so challenge only the form endpoint, never the whole site.

In Cloudflare, create a WAF custom rule with an expression like this, typed into the expression editor:

(http.request.uri.path eq "/wp-json/contact-form-7/v1/contact-forms/FORM_ID/feedback" and not ip.src.country in {"COUNTRY_CODE_A" "COUNTRY_CODE_B"})

FORM_ID is your form number from Step 3. COUNTRY_CODE_A and COUNTRY_CODE_B are two-letter ISO country codes for the places your customers are, such as US or GB; add as many as you need. ip.src.country is the current Cloudflare field name; the older ip.geoip.country is deprecated. Set the action to Managed Challenge rather than Block so a real person abroad can still get through. If real inquiries go quiet afterward, remove the rule.

Step 8: When people, not bots, are sending the spam

Spam that arrives at a human pace, passes Turnstile and pitches SEO or web design is probably typed by people. No bot check stops them. What helps:

  • Akismet, which recognizes the same pitch sent to thousands of other sites.
  • Field rules that make low-effort spam harder, such as a required project budget dropdown or a required question about which service they need.
  • Email filters that route common pitch phrases to a separate folder instead of deleting them.

The goal is a quiet inbox and zero lost leads, not zero spam.

Advanced: why client-side checks fail, and how to measure what works

Anything that runs in the visitor browser runs on a computer you do not control. A bot author can read your JavaScript, see which hidden field it fills, compute the same value and send it. JavaScript-only honeypots, “disable the submit button until the mouse moves” tricks, and front-end validation therefore only stop scripts that were never written for your form. Every check needs a server-side partner that decides whether to accept the entry. When you evaluate a plugin, ask one question: if I post to the endpoint with curl and leave this field out, does the server reject it? Step 3 shows how to test that.

Build a before and after baseline

Change one layer at a time and give each a few days. Track daily POSTs to the endpoint, entries saved versus marked spam, and real inquiries.

This command, run over SSH, prints POSTs to the endpoint per day:

grep "POST ENDPOINT_PATH" ACCESS_LOG | awk '{print substr($4,2,11)}' | sort | uniq -c

ENDPOINT_PATH is the path you found in Step 1, such as /wp-admin/admin-ajax.php. ACCESS_LOG is the log path from Step 1; add rotated logs by listing several files. substr($4,2,11) takes the date part of the timestamp column in the common log format, such as 07/Nov/2026. Good output shows the daily count dropping after a change, or staying the same while the spam count in the plugin rises, which means the layer is catching it. If real inquiries drop to zero after a change, undo the last layer first.

reCAPTCHA v3 thresholds, country rules and strict validation fail quietly, so after each change send a test from a phone on mobile data and from a VPN.

When to contact your host

Open a ticket if you need server-level rate limiting, if every log line shows a proxy IP instead of the visitor IP, or if the volume of POSTs is slowing the site. Send:

  • The form page URL and endpoint path.
  • UTC timestamps of a few spam submissions and the matching access log lines.
  • The top IP addresses from the Step 1 count, with request totals.
  • Which layers you already enabled and what changed after each.
  • If Cloudflare is involved, the Ray ID from a blocked request in Security events.

Ask for a scoped rule on the form endpoint, not a site-wide block. If the site itself is down because of the flood, start at the Emergency Room.

Still stuck?

Print a free one-page checklist to keep, or open the Emergency Room if the symptom does not match.

Get the free checklists

Keep reading

All field guides

Search the field manual

What is going wrong?

Press / to search from any page, Esc to close