DEV Community

Cover image for How to Block Bots on WordPress Without a CAPTCHA or a WAF
webdecoy
webdecoy

Posted on Originally published at webdecoy.com

How to Block Bots on WordPress Without a CAPTCHA or a WAF

Most WordPress bot protection works by estimating. It looks at a User-Agent, a request rate, a header set, and produces a guess. Good scoring gets you a long way — but every guess carries a false-positive tail, and on a checkout page that tail has a price.

There's a second approach that doesn't guess at all: set things that only a bot can touch.

This is a practical guide to doing that on WordPress. I'll use the WebDecoy plugin for the concrete examples because it's the one I work on and it's free and local, but the technique is the point — you could build most of it yourself in an afternoon.

Why deception is different from detection

A detector says "this request is 80% likely to be automated." A trap says "this request fetched a URL that appears nowhere in your sitemap, nowhere in your HTML, and is disallowed in robots.txt."

The second statement isn't a probability. A human browsing your site cannot accidentally do it.

That property is worth a lot, because the expensive failure in bot blocking isn't the bot you miss — it's the customer you block. A signal with no false-positive tail can drive a hard action (block, ban, refuse checkout) that you'd never dare trigger from a score alone.

So a sane architecture uses both: scoring for breadth, deception for certainty.

The four traps worth setting

1. Tripwire paths

Hidden URLs that only crawlers and scanners request. Nothing links to them, they're disallowed in robots.txt, and they don't appear in your sitemap. Anything that requests one has either ignored robots.txt or is enumerating paths.

WordPress has an unusually rich set of these available, because attackers probe the same handful of things on every WP site:

  • Fake vulnerable-plugin paths (/wp-content/plugins/<known-CVE-plugin>/…)
  • An XML-RPC trap
  • An author-enumeration canary (/?author=1 style probing)

These are the "public scanner" tier. They fire constantly on any site with a public IP, so treat them as a steady background signal rather than something to alert on.

2. Honeytoken links

An invisible decoy link injected into your pages. A human never sees it; a link-following scraper follows it.

The distinction from a tripwire path matters: a tripwire catches something guessing at URLs, a honeytoken catches something parsing your HTML and following every href. Different populations, and the second one is usually the scraper you actually care about.

Because nothing legitimate ever touches it, this is the one trap worth an alert.

A small war story on that: an earlier version of the plugin emailed the admin when the canary tripped. Sensible idea, terrible in practice — the canary link is on every public page, so busy sites got an email every hour, forever. It got removed one release later. Detections belong on a detections page, not in your inbox. If you build this yourself, learn from that: alert on the first trip per source, not every trip.

3. Deceptive files with canary credentials

This is the sharpest one. Serve plausible-looking responses for the files attackers always probe:

/.env
/wp-config.php.bak
/backup.sql
/phpinfo.php
Enter fullscreen mode Exit fullscreen mode

Instead of a 404, return a realistic file — seeded with per-site canary credentials that are valid nowhere.

Now you have a second-stage signal. Requesting /.env tells you someone is probing. Using the database password from that fake .env tells you someone read it and is trying it. That's not reconnaissance any more, that's an attempted intrusion, and it earns an immediate critical classification.

The per-site part matters: the canary has to be unique per install, or a single leaked credential list makes every site's canary identical and useless.

4. A decoy WooCommerce coupon

If you run a store: a hidden coupon code that appears nowhere a customer could find it. Coupon-scraping bots harvest and try codes in bulk. Applying that code at checkout is proof of automation — there is no innocent path to it.

This one is my favourite because it sits exactly where the money is. Card-testing and coupon-abuse bots both hit checkout, and checkout is where a false positive costs you an actual order. A deterministic signal there is worth more than anywhere else on the site.

Start in monitor mode. Actually do it.

The plugin ships in monitor mode by default — it records what it would have done without doing it. That default is correct and you should respect it rather than flipping to blocking on day one.

I wrote a whole piece on why bot detection false positives are a business event, and the short version applies here: you cannot know your false-positive rate until you've watched real traffic, including the weird tail of it — corporate proxies, carrier-grade NAT, accessibility tooling, your own uptime monitors.

Run it in monitor mode across at least one full weekly cycle. Then look specifically at what would have been blocked and ask whether you recognise anyone in there.

# check current mode and counts
wp webdecoy status

# watch first
wp webdecoy config set mode monitor

# ...then, once the would-block list looks clean
wp webdecoy config set mode block
Enter fullscreen mode Exit fullscreen mode

If you manage client sites, you can lock the mode in wp-config.php so a settings save can't silently drift it:

define( 'WEBDECOY_DEFAULT_MODE', 'monitor' );  // or 'block'
define( 'WEBDECOY_MAX_LOG_RETENTION', 90 );    // days, default 30
Enter fullscreen mode Exit fullscreen mode

One design detail I think is worth stealing: an unrecognised value for that constant is ignored rather than guessed. Forcing 'block' on a typo would start enforcing on a site that asked to watch; defaulting to 'monitor' would disarm one that asked to enforce. Neither is a safe guess, so it refuses to make one.

Verify the pipeline actually works

The failure mode nobody talks about with security plugins: it's installed, the dashboard is green, and it isn't actually inspecting anything — because a page cache sits in front of it, or the real client IP never arrives, or the scanner script is being stripped by an optimizer.

Two ways to prove it end to end:

Trip your own canary. The honeytoken's secret path is shown in the settings with a "trip it now" link. Open it and watch the detection land. That exercises the full path: request → trap → scoring → storage → UI.

Hit it with the reserved test User-Agent:

curl -A "WebDecoy-Test/1.0" https://your-site.example/
Enter fullscreen mode Exit fullscreen mode

The plugin records a detection and answers with a 403 JSON receipt, so the curl output itself shows it acted. The test never blocks your IP, never trips enforcement rules, and never fires alerts.

That second one is the check I'd want in a deploy script. A green admin page proves the plugin is installed; a 403 receipt proves it's in the request path.

Don't break your SEO on the way

The single most common way to hurt yourself here is blocking a crawler you needed.

Never allowlist by User-Agent string. Matching Googlebot and letting it through is a bypass, not an allowlist — anyone can send that string, and attackers do precisely because so many plugins trust it. Use forward-confirmed reverse DNS:

  1. Reverse-DNS the requesting IP to a hostname
  2. Check the hostname ends in a verified domain (.googlebot.com)
  3. Forward-resolve that hostname back to an IP
  4. Confirm it matches the original IP

Any decent plugin does this for you — WebDecoy ships a list of 60+ verified crawlers — but check that yours does, because "recognises Googlebot" and "verifies Googlebot" are very different claims.

AI crawlers are a separate decision from search crawlers. GPTBot, ClaudeBot, PerplexityBot and friends can be blocked independently of Googlebot and Bingbot, and you want that as its own switch rather than robots.txt surgery. Worth deciding deliberately: some AI search engines send referral traffic worth having, while pure training scrapers return nothing.

What this doesn't solve

Being honest about the limits, because "deception" can sound like a silver bullet:

  • Traps catch bots that explore. A scraper that only fetches linked, allowed pages at a human pace will never touch one. That population needs the scoring layer, not the trap layer.
  • A trap is one signal, not a policy. You still need to decide what a trip does — block, ban, challenge, refuse checkout — and for how long.
  • Application-layer blocking still costs you the request. The bot reached PHP. If you're being flooded rather than probed, you want blocking at the edge; application-layer detection is where you identify who to push there, not where you absorb volume.
  • Local means local. No third-party IP reputation, no cross-site intelligence, unless you opt into a cloud tier. That's a genuine tradeoff, not just a privacy talking point.

Install

wp plugin install webdecoy --activate
Enter fullscreen mode Exit fullscreen mode

Or Plugins → Add New → search "WebDecoy". Requires WordPress 6.1+ and PHP 7.4+ (tested to 7.0.x). GPL, free, and with no API key it makes zero external connections — front end or back end. Detection data lives in your own database and cleans itself up after 30 days.


The mental model I'd leave you with: scoring tells you who's probably a bot, deception tells you who definitely is. Most setups have plenty of the first and none of the second, and the second is the cheaper half to build.

If you've deployed honeypot paths or canary tokens on a production site — what actually tripped them first? On ours it's almost always a plugin-vulnerability scanner, long before anything interesting shows up.


Originally published at webdecoy.com.

Top comments (0)