Skip to content

How to Rate Limit Contact Form Spam in WordPress with Nginx (Trellis Setup)

· Upd. By Jasper Frumau DevOps

Your WordPress contact form plugin has spam protection — reCAPTCHA, honeypots, maybe a quiz field. And yet the spam keeps coming. That is because most anti-spam measures only work when the bot actually executes JavaScript and renders the form. A large portion of form spam never touches your frontend at all. Bots send a raw POST request directly to your form handler endpoint, skipping the page entirely, and your carefully configured honeypot never sees it.

The fix is not another WordPress plugin. It is a server-level rate limit in Nginx that throttles form submissions before they reach PHP. If an IP submits more than two forms per minute, Nginx drops the connection with a 429 and WordPress never has to process the request. Here is how we set this up on a Trellis stack — and the gotcha that made our first attempt miss half the spam.

Quick Summary: WordPress contact forms accept submissions through two paths: the REST API (AJAX) and a direct HTML form POST. Most rate limiting guides only cover the API endpoint. You need both. This post shows the complete Nginx configuration for Trellis, including a map-based zone that rate limits POST requests without affecting normal page visits.

Update (June 27, 2026): A few days after publishing this, a coordinated bot campaign hit one of our own contact forms. The rate limit did its job — it blocked more than half the submissions with a 429 — but it could not stop all of them. The attacker rotated across multiple IP addresses, including a Tor exit node, and every IP switch reset the per-IP counter. Per-IP rate limiting is a speed bump against a distributed attack, not a wall. We added a new section below — When Rate Limiting Is Not Enough — with the on-server, EU-friendly mitigations we now layer on top.

Why Plugin-Level Spam Protection Is Not Enough

Contact Form 7, WPForms, and Gravity Forms all offer built-in spam protection — typically reCAPTCHA, Cloudflare Turnstile, or honeypot fields. These work well against bots that actually load the page and execute JavaScript. The problem is the bots that do not.

When we reviewed access logs on one of our managed WordPress hosting clients, we found two spam submissions in a single 24-hour window. Both followed the same pattern:

  • Load the contact page via GET
  • Submit the form via POST within 1-4 seconds
  • No additional asset requests (no CSS, no JS, no images)

No human fills out a contact form in one second. These bots fetched the page HTML, parsed the form fields, and submitted a POST request directly — all without executing a single line of JavaScript. The honeypot field was never rendered, so it was never filled. The CAPTCHA challenge was never loaded, so it was never solved. The form submission went straight through.

One bot was running Chrome 67 on Windows 7 — a browser version from 2018. The other used a CCleaner-branded user agent. Both were clearly automated. Both succeeded because plugin-level spam protection only works at the application layer, and these requests were hitting the form handler directly at the HTTP layer.

The Two Submission Paths You Need to Cover

This is the part most rate limiting guides miss. WordPress contact forms accept submissions through two completely different URL paths:

Path 1: The REST API (AJAX). When a real user fills out a Contact Form 7 form and clicks submit, the browser sends an AJAX request to /wp-json/contact-form-7/v1/contact-forms/{id}/feedback. This is the modern, JavaScript-dependent path. It is what CF7 uses when everything works normally.

Path 2: Direct HTML POST. When JavaScript is disabled — or when a bot skips the frontend entirely — the form falls back to a standard HTML form submission: POST /contact/. This hits the page URL directly, and WordPress processes it through the regular request lifecycle. No AJAX, no REST API, no JavaScript required.

Most guides tell you to rate limit the REST API endpoint. That covers Path 1. But the spam bots we observed were using Path 2 exclusively — direct POST to the page URL. If you only rate limit the API, you are guarding the door the bots are not using.

The Complete Nginx Rate Limit Configuration

You need two rate limit zones in Nginx — one for each submission path. Here is the full setup as we deploy it on our Trellis stacks.

Step 1: Define the rate limit zones in nginx.conf

The first zone uses the client IP directly — every request to the CF7 REST API is a form submission by definition, so there is no need to filter by method. The second zone uses a map to create a key that is only populated for POST requests. GET requests get an empty key, which means Nginx skips rate limiting entirely for normal page visits.

# In the http {} block of nginx.conf

# Zone 1: CF7 REST API submissions
limit_req_zone $binary_remote_addr zone=cf7_form:10m rate=2r/m;

# Zone 2: Direct POST to contact page
map $request_method $contact_post_key {
    POST    $binary_remote_addr;
    default "";
}
limit_req_zone $contact_post_key zone=contact_post:10m rate=2r/m;

The rate=2r/m means two requests per minute per IP — one submission every 30 seconds. The 10m allocates 10 megabytes of shared memory for the zone, which is enough to track roughly 160,000 unique IP addresses. For a small business site, this is far more than you will ever need.

The map directive is the key piece. Without it, rate limiting the /contact/ location would throttle GET requests too — meaning a visitor browsing your contact page and then refreshing it could get blocked. The map ensures only POST requests count against the limit.

Step 2: Apply the zones to location blocks

Add both location blocks to your Nginx server configuration. On Trellis, this goes in an includes file:

# Rate limit Contact Form 7 REST API submissions
location ~* ^/wp-json/contact-form-7/ {
    limit_req zone=cf7_form burst=3 nodelay;
    limit_req_status 429;

    try_files $uri /index.php?$args;
    include fastcgi_params;
    fastcgi_param SERVER_NAME $host;
    fastcgi_param SCRIPT_FILENAME $realpath_root/index.php;
    fastcgi_param DOCUMENT_ROOT $realpath_root;
    fastcgi_pass unix:/var/run/php-fpm-wordpress.sock;
}

# Rate limit direct POST to /contact/
location = /contact/ {
    limit_req zone=contact_post burst=3 nodelay;
    limit_req_status 429;

    try_files $uri /index.php?$args;
    include fastcgi_params;
    fastcgi_param SERVER_NAME $host;
    fastcgi_param SCRIPT_FILENAME $realpath_root/index.php;
    fastcgi_param DOCUMENT_ROOT $realpath_root;
    fastcgi_pass unix:/var/run/php-fpm-wordpress.sock;
}

The burst=3 parameter allows a small burst of up to 3 requests before throttling kicks in — a legitimate user who double-clicks submit will not get blocked. The nodelay flag serves burst requests immediately instead of queuing them. The limit_req_status 429 returns HTTP 429 (Too Many Requests) instead of the default 503, which is semantically correct and tells the client exactly why the request was rejected.

Where the Files Go on Trellis

On a Trellis setup, Nginx configuration is managed through Ansible templates. You do not edit files on the server directly — you edit Jinja2 templates in your local repo and provision.

The rate limit zones go in the main Nginx config template. On a standard Trellis installation, this is trellis/roles/nginx/templates/nginx.conf.j2. Add the map and limit_req_zone directives inside the http {} block.

The location blocks go in an includes file. Trellis supports per-site and global includes via the nginx-includes/ directory. For a rule that should apply to all sites on the server, use the all/ subdirectory:

trellis/
├── nginx-includes/
│   └── all/
│       └── rate-limit-forms.conf.j2    ← location blocks go here
└── roles/
    └── nginx/
        └── templates/
            └── nginx.conf.j2           ← zone definitions go here

Note: If you modify nginx.conf.j2, you need to exclude it from automatic Trellis updates. Add --exclude="roles/nginx/templates/nginx.conf.j2" to your Trellis updater script’s rsync command, or your customization will be overwritten the next time you update Trellis.

Deploying the Configuration

Nginx configuration changes deploy through trellis provision, not trellis deploy. The provision command runs the Ansible playbook that renders your Jinja2 templates and reloads Nginx:

cd ~/your-project/trellis
trellis provision --tags nginx,wordpress-setup production

The --tags nginx,wordpress-setup flag limits the provision to Nginx-related tasks. The nginx tag deploys nginx.conf.j2 (where the zones are defined). The wordpress-setup tag deploys the includes files (where the location blocks live). You need both tags because the zone must exist before the location block can reference it.

Trellis runs nginx -t automatically before reloading. If there is a syntax error or a referenced zone does not exist, the provision will fail safely without taking down Nginx. We learned this the hard way — our first deploy used only --tags wordpress-setup and got a zero size shared memory zone "contact_post" error because the zone definition in nginx.conf.j2 had not been deployed yet.

Verifying It Works

After provisioning, confirm the configuration is live on the server:

# Check that the zone is defined in nginx.conf
ssh user@yourserver "grep 'contact_post' /etc/nginx/nginx.conf"

# Check that the location block is in the includes
ssh user@yourserver "cat /etc/nginx/includes.d/all/rate-limit-forms.conf"

To monitor whether the rate limit is actually catching spam, search for 429 responses in your access log:

# Find all rate-limited requests
ssh user@yourserver "grep '\" 429 ' /srv/www/yoursite.com/logs/access.log"

# Count rate-limited requests in the last 24 hours
ssh user@yourserver "grep '\" 429 ' /srv/www/yoursite.com/logs/access.log | \
  awk -v d=$(date -d '24 hours ago' '+%d/%b/%Y') '\$0 ~ d' | wc -l"

If you see 429 responses to POST /contact/ or POST /wp-json/contact-form-7/, the rate limit is working. If you see none, that either means no spam bots have hit the endpoint yet, or they are staying under the 2-per-minute threshold. Both are fine.

Verify the form still works for real users

Server-side checks confirm the config is deployed, but you also need to verify that real form submissions still go through. Open your contact page in a browser, open DevTools (F12 or Cmd+Option+I on Mac), switch to the Network tab, and filter by contact-form. Now fill out the form with test data and click Submit.

You should see three requests complete with status 200:

  1. GET /wp-json/contact-form-7/v1/contact-forms/{id}/feedback/schema200 (schema validation fetch)
  2. POST /wp-json/contact-form-7/v1/contact-forms/{id}/feedback200 (the actual form submission)
  3. GET /wp-json/contact-form-7/v1/contact-forms/{id}/refill200 (form reset after success)

If all three return 200 and the success message appears on the page, the AJAX submission path is working correctly. The new contact_post rate limit zone only targets direct POST requests to the page URL — the REST API path that real browsers use is a completely different location block and is unaffected.

If the POST request returns 429 instead, you have likely hit your own rate limit during testing. Wait 30 seconds and try again — the zone allows 2 requests per minute, so rapid repeated testing can trigger it on the REST API zone.

Automating the test with Playwright. If you use a headless browser framework like Playwright, you can script the entire verification. Navigate to the contact page, fill the form fields, click submit, and then inspect the network requests programmatically. This is what we did — Playwright filled the form, submitted it, and confirmed all three CF7 API requests returned 200 without manual intervention. Useful if you deploy rate limit changes across multiple sites and want a repeatable smoke test rather than clicking through DevTools each time.

We published the script we use as part of our open-source WP Ops repository: cf7-smoke-test.js. Run it against your contact page after any Nginx rate limit deploy:

npx playwright install chromium
node cf7-smoke-test.js https://yoursite.com/contact/

The script exits with code 0 on success and 1 on failure, so you can hook it into CI or a post-provision script. It accepts --name, --email, --subject, and --message flags to customize the test data.

Tip: Always test form submission after deploying rate limit changes. A misconfigured location block or missing fastcgi_pass directive can silently break form processing — the page loads fine, but submissions fail with a 500 or hang indefinitely.

Layering with Other Defenses

Rate limiting is one layer. It stops rapid-fire bot submissions but will not catch a bot that sends one spam message per hour. For comprehensive protection, layer it with:

  • Honeypot fields — a hidden form field that real users never see. Bots that auto-fill every field get caught. Contact Form 7 supports this natively with the [hidden] tag or through plugins like CF7 Honeypot.
  • An invisible CAPTCHA replacement — far less friction than reCAPTCHA v2 and more effective than reCAPTCHA v3 for most sites. Cloudflare Turnstile is the best-known option, but for GDPR and data-residency reasons we now favour EU, self-hostable proof-of-work challenges like ALTCHA or Friendly Captcha — see the update below for why. CF7 has integration plugins for all of these.
  • IP deny lists — for repeat offenders, add their IP to your Nginx deny list. On Trellis, this is a deny-ips.conf.j2 file in nginx-includes/all/ that drops connections with a 444 before the request reaches WordPress.
  • Antispam Bee — a WordPress plugin that checks comment and form submissions against known spam patterns. No API key required, GDPR-compliant, and effective as a last-resort filter at the application layer.

The goal is defense in depth. Nginx rate limiting handles the volume — bots that spray dozens of requests per minute. Honeypots and Turnstile handle the quality — single requests from bots that mimic real users. IP blocks handle the persistence — known bad actors who keep coming back. Together, they cover the full spectrum.

Update: When Rate Limiting Is Not Enough

The setup above is a real improvement, and we stand by it as a first layer. But we want to be honest about its ceiling. A few days after deploying it, we watched a coordinated campaign hit one of our own Contact Form 7 forms — and the logs make the limitation of per-IP rate limiting very clear.

This was a different class of bot from the crude ones earlier in this post. The bots that motivated the original rate limit were dumb — old browser versions, no JavaScript, one-second submissions, raw POSTs from a single IP. They were easy to throttle precisely because they were predictable. This campaign was the opposite: it behaved almost exactly like a fleet of real users.

In a five-minute window, the bot sent 33 POST requests to the CF7 REST endpoint. Nginx blocked 18 of them with a 429 — but 15 still went through and triggered notification emails. The reason was not a misconfigured rule. It was how deliberately the attacker mimicked legitimate traffic:

  • It used the real submission path. These were proper POST requests to the CF7 REST endpoint (/wp-json/contact-form-7/v1/contact-forms/{id}/feedback) — the same AJAX path a genuine browser uses, not the crude direct-POST fallback. The bot was executing or faithfully replaying the form’s JavaScript flow.
  • It distributed across multiple IPs. The requests came from four different addresses, including a known Tor exit node (185.220.101.104). Each IP sent only two or three submissions before handing off to the next — deliberately staying low enough per address to slip under a per-IP limit.
  • It reset the counter on every hop. Because the rate limit is keyed on the IP, every IP switch started a fresh bucket — so each new address got its own burst allowance before hitting 429. The rotation was almost certainly timed to exploit exactly this.
  • It presented a convincing fingerprint. A current Chrome user agent (Chrome 142 on macOS), a valid Referer pointing back at the contact page, and clean HTTP/2 requests. Nothing in the headers flagged it as automated.
  • It systematically harvested the page. Rather than hammering one URL, it walked every call-to-action link on the contact page — each with its own ?service= and ?source= query parameters — and submitted once from each, so the submissions looked like they originated from different entry points across the site.

The honeypot did not catch these either. The bot filled the visible fields correctly and left the hidden honeypot field empty — exactly what a real submission looks like. Modern form bots are written specifically to parse the form, identify decoy fields by their well-known names and display:none styling, and skip them. A static honeypot is a known quantity to them.

Put together, this is the profile of a tool built to defeat exactly the defenses most WordPress sites rely on. It speaks the real API, rotates its address to dodge IP limits, wears a believable browser fingerprint, and knows where the honeypot is. Volume-based and pattern-based filters — which is what rate limiting and honeypots are — have very little to grab onto. That is why the remaining defenses below shift toward reputation (who is this IP, across the whole internet?) and cost (make every submission expensive) rather than trying to spot a pattern that the bot has deliberately erased.

Why per-IP rate limiting has a ceiling

Our zones are keyed on $binary_remote_addr — the client IP. That is the right key for stopping a single-IP flood, and it still does that perfectly. But it means an attacker with N IP addresses effectively multiplies your limit by N. A botnet, a Tor circuit, or a cheap pool of datacenter proxies all defeat it the same way: spread the requests thin enough across enough addresses and each one stays under the threshold. Rate limiting cuts the volume roughly in half here — worth keeping — but it cannot be the only line of defense against a distributed attack.

On-server mitigations (no third-party service)

Our preference is to keep defenses on our own infrastructure and inside the EU, rather than routing traffic through a third-party proxy. These all run on the server itself:

  • CrowdSec — open-source, built by a French (EU) company, and the strongest on-server answer to distributed spam. It reads your Nginx logs, detects abusive behavior, and enforces blocks through an Nginx bouncer. Crucially, it also subscribes to a crowd-sourced reputation feed: an IP that misbehaved on someone else’s server gets blocked on yours before it submits anything. That shared signal is what per-IP rate limiting fundamentally lacks. Runs entirely on your box; the engine and bouncer are self-hosted.
  • fail2ban — simpler and already familiar to most server admins. Point a jail at the Nginx access log, match repeated 429s or form POSTs from one IP, and ban it at the firewall (nftables/iptables) for a few hours. It is less effective against fast IP rotation than CrowdSec, but it raises the cost for any IP that lingers, and it has zero external dependencies.
  • Block Tor exit nodes and abusive ASNs in Nginx — most of this traffic originates from Tor and cheap VPS ranges that legitimate customers almost never use. Pull the official Tor exit list (or a datacenter/proxy ASN list) on a cron job and feed it into the same deny list you already maintain in deny-ips.conf.j2. This would have stopped a third of the campaign above on its own.
  • Tighten the burst — dropping burst=3 to burst=1 (or removing it) closes the small initial-burst gap each fresh IP exploits. Minor on its own, but free.

EU-friendly challenges that beat the honeypot

Because the honeypot was defeated, the form itself needs a real challenge — but we avoid Cloudflare Turnstile and Google reCAPTCHA for GDPR and data-residency reasons. The good news is that the best replacements are proof-of-work challenges, which suit a distributed attack perfectly: they make every submission cost real CPU time, so a bot blasting hundreds of forms pays a price that scales against it — without tracking or profiling the visitor.

  • ALTCHA — fully self-hostable, proof-of-work, makes no third-party calls, and is GDPR-compliant by design. The most on-server-friendly option, with a Contact Form 7 integration available. This is the one that best matches our preference for keeping everything on our own server.
  • Friendly Captcha — a German company offering an invisible, GDPR-compliant proof-of-work widget. A hosted EU alternative to Turnstile if you would rather not self-host the challenge.
  • mCaptcha — open-source, self-hosted proof-of-work, for teams that want to run the whole challenge stack themselves.

Our revised recommendation: keep the Nginx rate limit as the cheap first layer — it still absorbs single-IP floods and cuts volume for free. Add CrowdSec (or at minimum fail2ban) for behavioral, reputation-aware IP blocking on the server. And put a self-hosted proof-of-work challenge like ALTCHA in front of the form to close the gap that rate limiting and honeypots leave open. That combination stays on your own infrastructure, keeps visitor data in the EU, and stops treating four IPs as four separate strangers.

Adapting for Other Form Plugins

The REST API path is different for each form plugin:

PluginREST API Endpoint
Contact Form 7/wp-json/contact-form-7/v1/contact-forms/
WPForms/wp-json/wpforms/
Gravity Forms/wp-json/gf/v2/
Formidable Forms/wp-json/frm/v2/

Swap the regex in the first location block to match your plugin. The direct POST location block stays the same regardless of which plugin you use — bots POST to whatever page URL the form is on.

If your contact form lives on a different page — say /get-in-touch/ instead of /contact/ — change the location = /contact/ directive accordingly. If you have forms on multiple pages, you can either add multiple location blocks or use a regex match for all of them.

Frequently Asked Questions

  • Will Nginx rate limiting block real users who submit the contact form? No. The limit is 2 requests per minute with a burst of 3. A real user submitting a form once — or even double-clicking the submit button — will never hit this threshold. Only automated submissions that fire multiple requests in rapid succession get blocked.
  • Do I need both location blocks for the REST API and direct POST? You need both. The REST API block catches AJAX submissions from browsers with JavaScript enabled. The direct POST block catches bots that skip JavaScript entirely and submit the HTML form directly. In our logs, all the spam came through the direct POST path — the REST API rate limit alone would have caught none of it.
  • What happens when a WordPress contact form request gets rate limited by Nginx? Nginx returns HTTP 429 (Too Many Requests) and closes the connection. The request never reaches PHP or WordPress. No email is sent, no form data is processed, and no server resources are consumed beyond the Nginx connection handling.
  • Can I use Nginx rate limiting on a non-Trellis WordPress server? Yes. The Nginx configuration is the same regardless of how you manage your server. The only difference is how you deploy it — on Trellis you edit Jinja2 templates and provision; on a manually managed server you edit the Nginx config files directly and reload with sudo nginx -s reload.
  • Does Nginx rate limiting work with Cloudflare or other CDN proxies? If your site is behind Cloudflare, $binary_remote_addr will contain Cloudflare’s IP, not the visitor’s. You need to use $http_x_forwarded_for or configure Nginx’s real_ip module to restore the original client IP before rate limiting will work correctly per visitor.

Done Managing Your Own Server?

We offer managed WordPress hosting built on Trellis — Nginx, PHP 8.3, Redis, automated deployments via Ansible, and Bedrock structure on Hetzner EU. No shared hosting, no page builders, no surprises.

  • Trellis + Bedrock on Hetzner EU (Frankfurt / Helsinki)
  • Nginx + FastCGI caching + Redis object cache
  • Automated deployments via Ansible, SSL via Let’s Encrypt
  • From €49/month — or €65/hour for one-off server work

Leave a Reply

Your email address will not be published.