The WebDecoy WordPress plugin ships with zero configuration required. But underneath the "install, activate, done" experience is a multi-layer detection engine that scores every request across server-side signals, client-side fingerprints, behavioral analysis, and proof-of-work verification.
This post walks through how each layer works, how they combine into a single threat score, and why this architecture catches bots that simpler approaches miss. It's a WordPress plugin, but the scoring design applies to any request pipeline.
The Detection Pipeline
Every request flows through a pipeline that evaluates it before WordPress processes it:
Incoming Request
│
├─ Is this IP blocked? → Yes → Block page
│
├─ Is this a known good bot? → Verify via reverse DNS → Allow
│
├─ Server-side analysis
│ ├─ User-Agent patterns
│ ├─ HTTP header consistency
│ ├─ MITRE ATT&CK path matching
│ └─ Rate limit check
│
├─ Client-side signals (on form submission)
│ ├─ WebDriver / headless detection
│ ├─ Automation framework markers
│ ├─ Canvas / WebGL fingerprint
│ └─ Behavioral scoring
│
├─ Proof-of-Work verification (on form submission)
│ └─ SHA-256 challenge validation
│
└─ Score aggregation → Allow / Challenge / Block
The first two checks are fast exits. Blocked IPs get rejected immediately. Verified good bots skip detection entirely. Everything else gets scored.
Threat Scoring: 0 to 100
Every detection signal adds points to a threat score. The score determines what happens to the request:
| Score Range | Severity | Action |
|---|---|---|
| 0–19 | Minimal | Allow (likely human) |
| 20–39 | Low | Log only |
| 40–59 | Medium | Optional challenge |
| 60–74 | High | Challenge or block |
| 75–100 | Critical | Automatic block |
The default blocking threshold is 75, configurable in settings. Scores at 40 and above are logged for review.
The scoring is additive. A request doesn't need to fail one dramatic test — it accumulates evidence across multiple signals. A slightly suspicious user agent (+25) combined with missing cookies (+15) and an unusual request path (+20) adds up to 60, enough to trigger a challenge. No single signal is conclusive, but the combination tells a clear story.
Base scores for common signals:
Missing standard headers: 10-30
No cookies on non-first visit: 15
Suspicious user agent: 25
Known bot user agent: 50
curl / wget / python-requests: 35
Automation tool detected: 40
Headless browser markers: 25
Rate limit exceeded: 25
Honeypot field triggered: 60
Fake bot (failed DNS verify): 80
A real Chrome browser hitting a normal page scores near zero. A Python script with a spoofed user agent, no cookies, and missing standard headers quickly crosses the blocking threshold.
Server-Side Analysis
User-Agent and header consistency
The plugin checks the User-Agent against known bot patterns (curl, wget, python-requests, Go-http-client, scrapy, and dozens more) and evaluates header consistency. Real browsers send a predictable set of headers — Accept, Accept-Language, Accept-Encoding, Connection — in a consistent order. Automated tools frequently omit headers or send them in unusual combinations.
Missing Accept-Language is a strong signal. Every real browser sends it. Most HTTP libraries don't unless explicitly configured.
MITRE ATT&CK path matching
This is one of the more distinctive pieces. Rather than maintaining an arbitrary blocklist of "bad" URLs, detection is organized by attacker tactic:
Credential Access (TA0006):
.env, wp-config.php, .git/, *.sql → +30 points
Collection (TA0009):
Backup files, database dumps → +25 points
Reconnaissance (TA0043):
Admin probes, user enumeration → +20 points
Discovery (TA0007):
Debug endpoints, phpinfo, server-status → +20 points
When an IP requests /wp-config.php.bak, then /.env, then /.git/config, each request scores individually while the rate limiter tracks velocity. The combined effect is rapid escalation to the blocking threshold.
The mapping isn't only for scoring. It surfaces in the detections table, so you can see a blocked IP was performing credential access reconnaissance rather than just "requesting bad URLs." The categorization tells you what attackers are actually looking for.
Rate limiting
Tracks requests per IP with a configurable window (default: 60 requests per 60 seconds). Exceeding it adds 25 points and can trigger automatic blocking.
The limiter uses the WordPress database for tracking, so it works behind load balancers and CDNs as long as the real client IP is forwarded in a standard header (X-Forwarded-For, X-Real-IP, or CF-Connecting-IP).
Client-Side Detection
The server-side layer catches unsophisticated bots. The client-side layer targets headless browsers, automation frameworks, and tools that spoof headers but can't perfectly replicate a real browser environment.
A scanner script loads with defer so it never blocks rendering, runs environment checks, and submits results alongside form data.
WebDriver detection — the simplest check. Selenium, Puppeteer, and Playwright all set navigator.webdriver = true by default. Stealth plugins override this, but it still catches unmodified tooling.
Headless markers — HeadlessChrome in the UA string, missing chrome.runtime and chrome.app objects (present in real Chrome, absent in headless), PhantomJS signatures on window.
Chrome consistency — a request claiming Chrome should have the chrome global with chrome.runtime, chrome.app, chrome.csi. If the UA says Chrome but these are missing or structurally wrong, the environment has been tampered with.
Behavioral scoring
For form submissions, the plugin evaluates how the user interacted with the page. This is where most sophisticated bots fail, because generating convincing human behavior at scale is genuinely hard.
Behavioral signals (40% weight):
- Mouse velocity variance
- Straight-line movement ratio
- Micro-tremor score (natural hand movement)
Environmental signals (35% weight):
- Headless browser markers
- Automation framework detection
- Browser API consistency
Temporal signals (15% weight):
- Time on page before submission
- Form completion velocity
- Session duration
Form signals (10% weight):
- Honeypot field triggers
- Field completion order
- Paste detection
Mouse velocity variance is particularly effective. Humans move with variable speed — accelerating, decelerating, overshooting, correcting. Bots that simulate movement typically use linear interpolation or simple easing functions, producing unnaturally smooth velocity profiles.
Straight-line movement ratio measures what percentage of movements travel in perfectly straight lines. Humans almost never do, because of micro-tremors and natural imprecision.
Micro-tremor score looks for the tiny involuntary oscillations present in all human hand movement. These have characteristic frequency patterns that are difficult to simulate; their absence suggests input generated by code.
Honeypot fields, rotated daily
Invisible form fields real users never see. If a field receives a value, the submission came from a bot that filled every input on the page.
What makes the implementation interesting is the obfuscation. Instead of obvious names like honeypot or trap, the plugin generates legitimate-looking field names that change daily:
// Field names rotate using a daily seed
// Examples of generated names:
// contact_name, user_email, address_field, phone_number
// CSS class prefixes mimic common form frameworks:
// form-, input-, wp-, cf-, gform-, ninja-
Daily rotation stops bot operators hardcoding a skip-list of honeypot names. The realistic naming defeats bots that filter for obvious trap patterns.
Proof-of-Work Challenges
The layer that makes automated attacks economically painful even when bots pass everything else.
When a form loads, the server generates a challenge: a random hex prefix and a difficulty parameter. The client must find a nonce where SHA-256(prefix + nonce) starts with N zero hex characters. There's no shortcut — it's brute force.
Server generates:
prefix: "a7f3c8e91b04d265" (16 hex chars from 8 random bytes)
difficulty: 4 (requires 4 leading zero hex chars)
expires: current_time + 5 minutes
signature: HMAC-SHA256(challenge_data, wordpress_auth_key)
Client computes:
nonce = 0: SHA-256("a7f3c8e91b04d265" + "0") = "7f2a..." (fail)
nonce = 1: SHA-256("a7f3c8e91b04d265" + "1") = "b391..." (fail)
...
nonce = N: SHA-256("a7f3c8e91b04d265" + "N") = "0000a..." (pass)
Client submits: { challengeId, nonce, signature }
At difficulty 4, the client tries roughly 65,536 hashes on average — milliseconds on modern hardware, entirely in the background while the user fills out the form. They never see it.
Why it stops bots: a single challenge is trivial, but the economics change at scale. A bot submitting 10,000 spam comments needs 10,000 challenges — about 655 million hash operations. Achievable, but it costs real compute.
Difficulty also scales with threat signals. An IP already flagged by server-side analysis gets harder challenges. Default difficulty 4 is intentionally low for normal users; suspicious traffic might face difficulty 6, roughly 16 million hashes per challenge.
Replay prevention
Each challenge carries an HMAC signature generated with WordPress's AUTH_KEY salt. The server verifies the signature before checking the hash, which prevents:
- Challenge reuse — each challenge ID is single-use
- Challenge tampering — difficulty and prefix are signed, so they can't be modified
-
Challenge forging — without
AUTH_KEY, valid signatures can't be generated - Stockpiling — a 5-minute TTL kills pre-solved challenges
Signing rather than storing means the server never writes issued challenges to the database. That keeps the table clean and removes a DoS vector where an attacker floods the challenge endpoint to fill storage.
Good Bot Verification
Not all bots are bad. Googlebot, Bingbot, and 60+ other legitimate crawlers need unimpeded access for indexing, previews, uptime monitoring, and SEO tooling.
Search engines: Googlebot, Bingbot, YandexBot, Baiduspider,
DuckDuckBot, Applebot
Social: Facebook, LinkedIn, Twitter, Pinterest
Monitoring: Pingdom, UptimeRobot, StatusCake, Datadog
SEO tools: Ahrefs, SEMrush, Moz, Majestic
Feed readers: Feedly, NewsBlur
AI crawlers: GPTBot, ClaudeBot, PerplexityBot (optional blocking)
For verifiable bots, the plugin does forward-confirmed reverse DNS:
- Look up the requesting IP's hostname via reverse DNS
- Check the hostname ends with a verified domain (
.googlebot.com,.google.com) - Forward-resolve that hostname back to an IP
- Confirm it matches the original requesting IP
A fake Googlebot scores +80 — an instant block — because spoofing a search crawler is a strong signal of intent. Results cache in WordPress transients with a 1-hour TTL to avoid repeated lookups.
This matters more than it sounds: matching Googlebot in a User-Agent string and allowing it is a bypass, not an allowlist. Anyone can send that string.
WooCommerce: carding defense
Attackers test stolen card numbers against real checkout flows. Every failed transaction generates processor fees, and a high decline rate can get your payment processing suspended.
Checkout velocity limiting — configurable max checkout attempts per IP per window (default: 5 per hour). Legitimate shoppers rarely attempt checkout more than once or twice. An IP submitting 20 attempts in an hour is testing cards.
Card testing pattern detection — multiple different card numbers from one IP, rapid sequential attempts, and headless signatures on the checkout page all trigger detection, blocking before further transactions reach the processor.
Compatible with classic checkout and WooCommerce Blocks, and declares HPOS (High-Performance Order Storage) compatibility.
Architecture decisions worth calling out
No external dependencies for core protection. The entire detection engine runs on your server. No API calls during request processing, no third-party JavaScript on the frontend. Protection works during API outages, on airgapped installs, and at any traffic volume without per-request costs. Even the admin dashboard's charting library is bundled into the plugin rather than pulled from a CDN, so the plugin makes zero external connections unless you explicitly add a Cloud API key.
Additive scoring over binary decisions. Every signal adds to a score rather than making a pass/fail call. This dramatically reduces false positives: a single suspicious signal might be coincidence, five together are a pattern. No single check that misfires can block a legitimate user on its own.
Daily rotating honeypot names. Static names get learned and skip-listed. Seeded rotation changes them daily while staying deterministic, so the server can verify which fields are honeypots without storing state.
HMAC-signed challenges instead of stored ones. The signature itself proves the challenge is legitimate and unmodified — no database writes, no storage-exhaustion vector.
Getting started
wp plugin install webdecoy --activate
Or: Plugins → Add New → search "WebDecoy" → Install → Activate.
Defaults (sensitivity medium, block threshold 75, rate limit 60/min, PoW difficulty 4) work for most sites. Requires WordPress 6.1+ and PHP 7.4+. GPL-licensed.
If you're building request scoring in any stack, the transferable idea here is the additive model: resist the urge to make any single signal decisive, and let evidence accumulate instead. It's the difference between a detector that's occasionally spectacularly wrong and one that's boringly right.
How do you handle bot scoring — hard rules or weighted signals? Curious what thresholds other people have landed on.
Originally published at webdecoy.com.
Related reading:
Top comments (0)