<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: webdecoy</title>
    <description>The latest articles on DEV Community by webdecoy (@webdecoy).</description>
    <link>https://dev.to/webdecoy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F96509%2F575bf9aa-10c6-43d0-9624-7f180f9fabfe.png</url>
      <title>DEV Community: webdecoy</title>
      <link>https://dev.to/webdecoy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/webdecoy"/>
    <language>en</language>
    <item>
      <title>How to Block Bots on WordPress Without a CAPTCHA or a WAF</title>
      <dc:creator>webdecoy</dc:creator>
      <pubDate>Sun, 13 Sep 2026 18:32:08 +0000</pubDate>
      <link>https://dev.to/webdecoy/how-to-block-bots-on-wordpress-without-a-captcha-or-a-waf-38l</link>
      <guid>https://dev.to/webdecoy/how-to-block-bots-on-wordpress-without-a-captcha-or-a-waf-38l</guid>
      <description>&lt;p&gt;Most WordPress bot protection works by &lt;em&gt;estimating&lt;/em&gt;. 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.&lt;/p&gt;

&lt;p&gt;There's a second approach that doesn't guess at all: &lt;strong&gt;set things that only a bot can touch.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is a practical guide to doing that on WordPress. I'll use the &lt;a href="https://wordpress.org/plugins/webdecoy/" rel="noopener noreferrer"&gt;WebDecoy plugin&lt;/a&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why deception is different from detection
&lt;/h2&gt;

&lt;p&gt;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."&lt;/p&gt;

&lt;p&gt;The second statement isn't a probability. &lt;strong&gt;A human browsing your site cannot accidentally do it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So a sane architecture uses both: scoring for breadth, deception for certainty.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four traps worth setting
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Tripwire paths
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;WordPress has an unusually rich set of these available, because attackers probe the same handful of things on every WP site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fake vulnerable-plugin paths (&lt;code&gt;/wp-content/plugins/&amp;lt;known-CVE-plugin&amp;gt;/…&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;An XML-RPC trap&lt;/li&gt;
&lt;li&gt;An author-enumeration canary (&lt;code&gt;/?author=1&lt;/code&gt; style probing)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Honeytoken links
&lt;/h3&gt;

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

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

&lt;p&gt;Because nothing legitimate ever touches it, this is the one trap worth an alert.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;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 &lt;em&gt;every public page&lt;/em&gt;, 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 &lt;em&gt;first&lt;/em&gt; trip per source, not every trip.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  3. Deceptive files with canary credentials
&lt;/h3&gt;

&lt;p&gt;This is the sharpest one. Serve plausible-looking responses for the files attackers always probe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/.env
/wp-config.php.bak
/backup.sql
/phpinfo.php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of a 404, return a realistic file — seeded with &lt;strong&gt;per-site canary credentials that are valid nowhere&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now you have a second-stage signal. Requesting &lt;code&gt;/.env&lt;/code&gt; tells you someone is probing. &lt;em&gt;Using&lt;/em&gt; the database password from that fake &lt;code&gt;.env&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. A decoy WooCommerce coupon
&lt;/h3&gt;

&lt;p&gt;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. &lt;strong&gt;Applying that code at checkout is proof of automation&lt;/strong&gt; — there is no innocent path to it.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start in monitor mode. Actually do it.
&lt;/h2&gt;

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

&lt;p&gt;I wrote a whole piece on &lt;a href="https://webdecoy.com/blog/bot-detection-false-positives-testing-benchmark/" rel="noopener noreferrer"&gt;why bot detection false positives are a business event&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;Run it in monitor mode across at least one full weekly cycle. Then look specifically at what &lt;em&gt;would&lt;/em&gt; have been blocked and ask whether you recognise anyone in there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# check current mode and counts&lt;/span&gt;
wp webdecoy status

&lt;span class="c"&gt;# watch first&lt;/span&gt;
wp webdecoy config &lt;span class="nb"&gt;set &lt;/span&gt;mode monitor

&lt;span class="c"&gt;# ...then, once the would-block list looks clean&lt;/span&gt;
wp webdecoy config &lt;span class="nb"&gt;set &lt;/span&gt;mode block
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you manage client sites, you can lock the mode in &lt;code&gt;wp-config.php&lt;/code&gt; so a settings save can't silently drift it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WEBDECOY_DEFAULT_MODE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'monitor'&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// or 'block'&lt;/span&gt;
&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'WEBDECOY_MAX_LOG_RETENTION'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// days, default 30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  Verify the pipeline actually works
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Two ways to prove it end to end:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trip your own canary.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hit it with the reserved test User-Agent:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"WebDecoy-Test/1.0"&lt;/span&gt; https://your-site.example/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;h2&gt;
  
  
  Don't break your SEO on the way
&lt;/h2&gt;

&lt;p&gt;The single most common way to hurt yourself here is blocking a crawler you needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never allowlist by User-Agent string.&lt;/strong&gt; Matching &lt;code&gt;Googlebot&lt;/code&gt; 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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reverse-DNS the requesting IP to a hostname&lt;/li&gt;
&lt;li&gt;Check the hostname ends in a verified domain (&lt;code&gt;.googlebot.com&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Forward-resolve that hostname back to an IP&lt;/li&gt;
&lt;li&gt;Confirm it matches the original IP&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI crawlers are a separate decision from search crawlers.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't solve
&lt;/h2&gt;

&lt;p&gt;Being honest about the limits, because "deception" can sound like a silver bullet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Traps catch bots that explore.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A trap is one signal, not a policy.&lt;/strong&gt; You still need to decide what a trip &lt;em&gt;does&lt;/em&gt; — block, ban, challenge, refuse checkout — and for how long.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Application-layer blocking still costs you the request.&lt;/strong&gt; The bot reached PHP. If you're being flooded rather than probed, you want blocking at the edge; application-layer detection is where you &lt;em&gt;identify&lt;/em&gt; who to push there, not where you absorb volume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local means local.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp plugin &lt;span class="nb"&gt;install &lt;/span&gt;webdecoy &lt;span class="nt"&gt;--activate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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




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

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://webdecoy.com/blog/block-bots-wordpress-deception-without-captcha/" rel="noopener noreferrer"&gt;webdecoy.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Bot Protection for WordPress Without CAPTCHAs or API Keys</title>
      <dc:creator>webdecoy</dc:creator>
      <pubDate>Sun, 13 Sep 2026 18:29:59 +0000</pubDate>
      <link>https://dev.to/webdecoy/bot-protection-for-wordpress-without-captchas-or-api-keys-38gj</link>
      <guid>https://dev.to/webdecoy/bot-protection-for-wordpress-without-captchas-or-api-keys-38gj</guid>
      <description>&lt;p&gt;Most WordPress security plugins ask you to configure rules, connect APIs, tune thresholds, and then hope you got it right. We went the other way: install, activate, done. Every protection layer works immediately with zero configuration and no API key.&lt;/p&gt;

&lt;p&gt;This post is about &lt;em&gt;why&lt;/em&gt; that design, and what it actually stops.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two camps, and why neither worked
&lt;/h2&gt;

&lt;p&gt;WordPress powers a large share of the web and is the most targeted CMS for automated attacks. Comment spam, brute force logins, fake registrations, credential stuffing, and WooCommerce carding are daily realities for site owners.&lt;/p&gt;

&lt;p&gt;Existing solutions fall into two camps:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CAPTCHA-based plugins&lt;/strong&gt; force every visitor to prove they're human. Conversion rates drop. Accessibility suffers. And vision models can now solve image challenges programmatically — so you're paying a UX tax for a test the attacker passes and your customer resents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API-dependent plugins&lt;/strong&gt; require accounts, keys, and external services. They charge per request, which means &lt;strong&gt;costs spike during attacks — exactly when you need protection most&lt;/strong&gt;. If the API goes down, your protection disappears with it.&lt;/p&gt;

&lt;p&gt;So: all detection runs locally, on your server and in the visitor's browser. No external dependencies. No per-request billing. No CAPTCHAs. Humans never see a challenge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero-config, and what that actually means
&lt;/h2&gt;

&lt;p&gt;Earlier versions required an API key before protection hooks would activate. That requirement is gone. Every detection layer — server-side analysis, client-side fingerprinting, proof-of-work challenges, rate limiting — runs locally without any external connection.&lt;/p&gt;

&lt;p&gt;An optional Cloud integration adds threat intelligence feeds, VPN detection, and cross-site threat sharing for teams managing multiple installs. &lt;strong&gt;The core protection needs none of it.&lt;/strong&gt; That's the part worth stressing: the free path isn't a crippled trial, it's the whole detection engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  SHA-256 proof-of-work instead of a CAPTCHA
&lt;/h2&gt;

&lt;p&gt;Rather than showing a challenge, the plugin issues invisible SHA-256 proof-of-work. The browser computes a valid hash before a form submission is accepted. For humans on modern hardware this takes milliseconds and is completely invisible. For bots running thousands of concurrent sessions, the compute cost makes attacks economically unviable.&lt;/p&gt;

&lt;p&gt;Same principle as &lt;a href="https://github.com/WebDecoy/FCaptcha" rel="noopener noreferrer"&gt;FCaptcha&lt;/a&gt;, our open-source CAPTCHA replacement, adapted for WordPress's form handling.&lt;/p&gt;

&lt;h2&gt;
  
  
  4-factor behavioral scoring
&lt;/h2&gt;

&lt;p&gt;Every form submission is evaluated across four weighted signal categories:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Behavioral&lt;/strong&gt; (40%) — interaction timing, keystroke patterns, mouse movement characteristics&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environmental&lt;/strong&gt; (35%) — browser consistency checks, headless detection, automation framework markers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temporal&lt;/strong&gt; (15%) — time-on-page, submission velocity, session duration anomalies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Form&lt;/strong&gt; (10%) — honeypot triggers, field completion order, paste detection&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each category contributes a weighted score; the combined result decides allow, flag, or block. &lt;strong&gt;No single signal is decisive&lt;/strong&gt; — the system looks at the full picture. That's deliberate: binary checks are how you end up blocking a real customer on a Tuesday because they had one unusual header.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it protects
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Comment and form spam.&lt;/strong&gt; Invisible detection is injected into comment, login, and registration forms. Honeypot fields catch simple bots, proof-of-work stops headless browsers, behavioral scoring catches everything in between. Hooks activate automatically for standard WordPress forms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Login and registration attacks.&lt;/strong&gt; Brute force and fake registrations are blocked through rate limiting and behavioral analysis. The plugin tracks failed-login velocity per IP and blocks sources that exceed thresholds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WooCommerce carding.&lt;/strong&gt; Fraudsters test stolen cards against your checkout. Every declined transaction generates processor fees — enough declines and your payment provider drops you. The plugin detects the pattern: multiple small transactions from one IP, rapid checkout velocity, different card numbers in quick succession, headless signatures on the checkout page. Detected carders are blocked before reaching your payment processor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Content scraping and AI crawlers.&lt;/strong&gt; Identifies and blocks unauthorized scrapers including GPTBot, ClaudeBot, and others. Legitimate crawlers — Googlebot, Bingbot, and 60+ more — are verified through &lt;strong&gt;forward-confirmed reverse DNS&lt;/strong&gt;, not by trusting the User-Agent string, and always allowed through. Your SEO stays intact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MITRE ATT&amp;amp;CK path analysis.&lt;/strong&gt; Requests probing for &lt;code&gt;wp-config.php&lt;/code&gt;, admin endpoints, backup files, and other sensitive paths are flagged as reconnaissance — not just logged, but fed into the scoring model as signals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under the hood: two layers that cross-reference
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Server-side analysis&lt;/strong&gt; examines every request before WordPress processes it: user-agent patterns, header consistency, request rates, IP reputation, path analysis. Known good bots are verified via reverse DNS and exempted immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Client-side fingerprinting&lt;/strong&gt; runs in the visitor's browser checking for headless markers, automation framework signatures (Playwright, Puppeteer, Selenium), browser API consistency, and environmental anomalies. The fingerprint is submitted alongside form data and validated server-side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When the two layers disagree — a request claims to be Chrome but client-side checks detect Playwright markers — the threat score increases significantly.&lt;/strong&gt; The disagreement &lt;em&gt;is&lt;/em&gt; the signal.&lt;/p&gt;

&lt;h3&gt;
  
  
  IP management
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Individual addresses and CIDR ranges&lt;/li&gt;
&lt;li&gt;IPv4 and IPv6&lt;/li&gt;
&lt;li&gt;Optional expiration (temporary blocks that auto-release)&lt;/li&gt;
&lt;li&gt;Bulk operations for large block lists&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Expiring blocks matter more than they look. A permanent block list is a growing pile of decisions nobody revisits, and shared IPs rotate — today's abusive address is next month's office NAT.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local dashboard
&lt;/h2&gt;

&lt;p&gt;A statistics page with trend visualization: detection volume over 30 days, threat type distribution (spam bots, scrapers, credential stuffing, carding), top blocked IPs and their attack patterns, and source analysis. The detections interface supports date filtering, CSV export, and bulk operations.&lt;/p&gt;

&lt;p&gt;All of it renders from &lt;strong&gt;your own database&lt;/strong&gt;. Detection data cleans itself up after 30 days, and the charting library is bundled into the plugin rather than loaded from a CDN — so the dashboard makes no third-party requests either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp plugin &lt;span class="nb"&gt;install &lt;/span&gt;webdecoy &lt;span class="nt"&gt;--activate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or &lt;strong&gt;Plugins → Add New → search "WebDecoy"&lt;/strong&gt;. Protection starts immediately; there is no step three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Requirements:&lt;/strong&gt; WordPress 6.1+, PHP 7.4+. GPL v2, open source at &lt;a href="https://github.com/WebDecoy/wordpress-plugin" rel="noopener noreferrer"&gt;github.com/WebDecoy/wordpress-plugin&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you want the detection internals — the scoring table, the MITRE mapping, the HMAC-signed proof-of-work design — I wrote those up separately in &lt;a href="https://webdecoy.com/blog/how-webdecoy-wordpress-plugin-detects-bots/" rel="noopener noreferrer"&gt;Inside a WordPress Bot Detection Engine&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you run WooCommerce: have you actually measured your decline rate from card testing? It's the one bot cost that shows up directly on a processor invoice, and most stores never look.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://webdecoy.com/blog/webdecoy-wordpress-plugin-v2-zero-config-bot-protection/" rel="noopener noreferrer"&gt;webdecoy.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related reading:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/carding-attacks-webdecoy-protection/" rel="noopener noreferrer"&gt;Carding Attacks Explained: Stop Bots Before Checkout&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/why-captchas-are-dead-and-what-replaces-them-in-2026/" rel="noopener noreferrer"&gt;Why CAPTCHAs Are Dead (And What Replaces Them in 2026)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>wordpress</category>
      <category>woocommerce</category>
      <category>security</category>
      <category>php</category>
    </item>
    <item>
      <title>Inside a WordPress Bot Detection Engine</title>
      <dc:creator>webdecoy</dc:creator>
      <pubDate>Sun, 13 Sep 2026 16:54:36 +0000</pubDate>
      <link>https://dev.to/webdecoy/inside-a-wordpress-bot-detection-engine-mm3</link>
      <guid>https://dev.to/webdecoy/inside-a-wordpress-bot-detection-engine-mm3</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Detection Pipeline
&lt;/h2&gt;

&lt;p&gt;Every request flows through a pipeline that evaluates it before WordPress processes it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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&amp;amp;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first two checks are fast exits. Blocked IPs get rejected immediately. Verified good bots skip detection entirely. Everything else gets scored.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threat Scoring: 0 to 100
&lt;/h2&gt;

&lt;p&gt;Every detection signal adds points to a threat score. The score determines what happens to the request:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Score Range&lt;/th&gt;
&lt;th&gt;Severity&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0–19&lt;/td&gt;
&lt;td&gt;Minimal&lt;/td&gt;
&lt;td&gt;Allow (likely human)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20–39&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Log only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;40–59&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Optional challenge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;60–74&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Challenge or block&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;75–100&lt;/td&gt;
&lt;td&gt;Critical&lt;/td&gt;
&lt;td&gt;Automatic block&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The default blocking threshold is 75, configurable in settings. Scores at 40 and above are logged for review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The scoring is additive.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;Base scores for common signals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;Missing&lt;/span&gt; &lt;span class="k"&gt;standard&lt;/span&gt; &lt;span class="nv"&gt;headers:&lt;/span&gt;      &lt;span class="mf"&gt;10&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;30&lt;/span&gt;
&lt;span class="k"&gt;No&lt;/span&gt; &lt;span class="k"&gt;cookies&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;non&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="k"&gt;first&lt;/span&gt; &lt;span class="nv"&gt;visit:&lt;/span&gt;    &lt;span class="mf"&gt;15&lt;/span&gt;
&lt;span class="k"&gt;Suspicious&lt;/span&gt; &lt;span class="k"&gt;user&lt;/span&gt; &lt;span class="nv"&gt;agent:&lt;/span&gt;            &lt;span class="mf"&gt;25&lt;/span&gt;
&lt;span class="k"&gt;Known&lt;/span&gt; &lt;span class="k"&gt;bot&lt;/span&gt; &lt;span class="k"&gt;user&lt;/span&gt; &lt;span class="nv"&gt;agent:&lt;/span&gt;             &lt;span class="mf"&gt;50&lt;/span&gt;
&lt;span class="k"&gt;curl&lt;/span&gt; &lt;span class="err"&gt;/&lt;/span&gt; &lt;span class="k"&gt;wget&lt;/span&gt; &lt;span class="err"&gt;/&lt;/span&gt; &lt;span class="nv"&gt;python-requests:&lt;/span&gt;    &lt;span class="mf"&gt;35&lt;/span&gt;
&lt;span class="k"&gt;Automation&lt;/span&gt; &lt;span class="k"&gt;tool&lt;/span&gt; &lt;span class="nv"&gt;detected:&lt;/span&gt;         &lt;span class="mf"&gt;40&lt;/span&gt;
&lt;span class="k"&gt;Headless&lt;/span&gt; &lt;span class="k"&gt;browser&lt;/span&gt; &lt;span class="nv"&gt;markers:&lt;/span&gt;         &lt;span class="mf"&gt;25&lt;/span&gt;
&lt;span class="k"&gt;Rate&lt;/span&gt; &lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="nv"&gt;exceeded:&lt;/span&gt;              &lt;span class="mf"&gt;25&lt;/span&gt;
&lt;span class="k"&gt;Honeypot&lt;/span&gt; &lt;span class="k"&gt;field&lt;/span&gt; &lt;span class="nv"&gt;triggered:&lt;/span&gt;         &lt;span class="mf"&gt;60&lt;/span&gt;
&lt;span class="k"&gt;Fake&lt;/span&gt; &lt;span class="k"&gt;bot&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;failed&lt;/span&gt; &lt;span class="k"&gt;DNS&lt;/span&gt; &lt;span class="k"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;     &lt;span class="mf"&gt;80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server-Side Analysis
&lt;/h2&gt;

&lt;h3&gt;
  
  
  User-Agent and header consistency
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing &lt;code&gt;Accept-Language&lt;/code&gt; is a strong signal.&lt;/strong&gt; Every real browser sends it. Most HTTP libraries don't unless explicitly configured.&lt;/p&gt;

&lt;h3&gt;
  
  
  MITRE ATT&amp;amp;CK path matching
&lt;/h3&gt;

&lt;p&gt;This is one of the more distinctive pieces. Rather than maintaining an arbitrary blocklist of "bad" URLs, detection is organized by attacker &lt;em&gt;tactic&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Credential Access (TA0006)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s"&gt;.env, wp-config.php, .git/, *.sql         → +30 points&lt;/span&gt;

&lt;span class="na"&gt;Collection (TA0009)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s"&gt;Backup files, database dumps              → +25 points&lt;/span&gt;

&lt;span class="na"&gt;Reconnaissance (TA0043)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s"&gt;Admin probes, user enumeration            → +20 points&lt;/span&gt;

&lt;span class="na"&gt;Discovery (TA0007)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s"&gt;Debug endpoints, phpinfo, server-status   → +20 points&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When an IP requests &lt;code&gt;/wp-config.php.bak&lt;/code&gt;, then &lt;code&gt;/.env&lt;/code&gt;, then &lt;code&gt;/.git/config&lt;/code&gt;, each request scores individually while the rate limiter tracks velocity. The combined effect is rapid escalation to the blocking threshold.&lt;/p&gt;

&lt;p&gt;The mapping isn't only for scoring. It surfaces in the detections table, so you can see a blocked IP was performing &lt;em&gt;credential access reconnaissance&lt;/em&gt; rather than just "requesting bad URLs." The categorization tells you what attackers are actually looking for.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rate limiting
&lt;/h3&gt;

&lt;p&gt;Tracks requests per IP with a configurable window (default: 60 requests per 60 seconds). Exceeding it adds 25 points and can trigger automatic blocking.&lt;/p&gt;

&lt;p&gt;The limiter uses the WordPress database for tracking, so it works behind load balancers and CDNs &lt;strong&gt;as long as the real client IP is forwarded&lt;/strong&gt; in a standard header (&lt;code&gt;X-Forwarded-For&lt;/code&gt;, &lt;code&gt;X-Real-IP&lt;/code&gt;, or &lt;code&gt;CF-Connecting-IP&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Client-Side Detection
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;A scanner script loads with &lt;code&gt;defer&lt;/code&gt; so it never blocks rendering, runs environment checks, and submits results alongside form data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebDriver detection&lt;/strong&gt; — the simplest check. Selenium, Puppeteer, and Playwright all set &lt;code&gt;navigator.webdriver = true&lt;/code&gt; by default. Stealth plugins override this, but it still catches unmodified tooling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Headless markers&lt;/strong&gt; — &lt;code&gt;HeadlessChrome&lt;/code&gt; in the UA string, missing &lt;code&gt;chrome.runtime&lt;/code&gt; and &lt;code&gt;chrome.app&lt;/code&gt; objects (present in real Chrome, absent in headless), PhantomJS signatures on &lt;code&gt;window&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chrome consistency&lt;/strong&gt; — a request claiming Chrome should have the &lt;code&gt;chrome&lt;/code&gt; global with &lt;code&gt;chrome.runtime&lt;/code&gt;, &lt;code&gt;chrome.app&lt;/code&gt;, &lt;code&gt;chrome.csi&lt;/code&gt;. If the UA says Chrome but these are missing or structurally wrong, the environment has been tampered with.&lt;/p&gt;

&lt;h3&gt;
  
  
  Behavioral scoring
&lt;/h3&gt;

&lt;p&gt;For form submissions, the plugin evaluates &lt;em&gt;how&lt;/em&gt; the user interacted with the page. This is where most sophisticated bots fail, because generating convincing human behavior at scale is genuinely hard.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Behavioral signals (40% weight)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Mouse velocity variance&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Straight-line movement ratio&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Micro-tremor score (natural hand movement)&lt;/span&gt;

&lt;span class="na"&gt;Environmental signals (35% weight)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Headless browser markers&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Automation framework detection&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Browser API consistency&lt;/span&gt;

&lt;span class="na"&gt;Temporal signals (15% weight)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Time on page before submission&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Form completion velocity&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Session duration&lt;/span&gt;

&lt;span class="na"&gt;Form signals (10% weight)&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Honeypot field triggers&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Field completion order&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Paste detection&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Mouse velocity variance&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Straight-line movement ratio&lt;/strong&gt; measures what percentage of movements travel in perfectly straight lines. Humans almost never do, because of micro-tremors and natural imprecision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Micro-tremor score&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Honeypot fields, rotated daily
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;What makes the implementation interesting is the obfuscation. Instead of obvious names like &lt;code&gt;honeypot&lt;/code&gt; or &lt;code&gt;trap&lt;/code&gt;, the plugin generates legitimate-looking field names that &lt;strong&gt;change daily&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Field names rotate using a daily seed&lt;/span&gt;
&lt;span class="c1"&gt;// Examples of generated names:&lt;/span&gt;
&lt;span class="c1"&gt;//   contact_name, user_email, address_field, phone_number&lt;/span&gt;
&lt;span class="c1"&gt;// CSS class prefixes mimic common form frameworks:&lt;/span&gt;
&lt;span class="c1"&gt;//   form-, input-, wp-, cf-, gform-, ninja-&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Daily rotation stops bot operators hardcoding a skip-list of honeypot names. The realistic naming defeats bots that filter for obvious trap patterns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof-of-Work Challenges
&lt;/h2&gt;

&lt;p&gt;The layer that makes automated attacks economically painful even when bots pass everything else.&lt;/p&gt;

&lt;p&gt;When a form loads, the server generates a challenge: a random hex prefix and a difficulty parameter. The client must find a nonce where &lt;code&gt;SHA-256(prefix + nonce)&lt;/code&gt; starts with N zero hex characters. There's no shortcut — it's brute force.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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 }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At difficulty 4, the client tries roughly &lt;strong&gt;65,536 hashes&lt;/strong&gt; on average — milliseconds on modern hardware, entirely in the background while the user fills out the form. They never see it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it stops bots:&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Replay prevention
&lt;/h3&gt;

&lt;p&gt;Each challenge carries an HMAC signature generated with WordPress's &lt;code&gt;AUTH_KEY&lt;/code&gt; salt. The server verifies the signature before checking the hash, which prevents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Challenge reuse&lt;/strong&gt; — each challenge ID is single-use&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Challenge tampering&lt;/strong&gt; — difficulty and prefix are signed, so they can't be modified&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Challenge forging&lt;/strong&gt; — without &lt;code&gt;AUTH_KEY&lt;/code&gt;, valid signatures can't be generated&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stockpiling&lt;/strong&gt; — a 5-minute TTL kills pre-solved challenges&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Good Bot Verification
&lt;/h2&gt;

&lt;p&gt;Not all bots are bad. Googlebot, Bingbot, and 60+ other legitimate crawlers need unimpeded access for indexing, previews, uptime monitoring, and SEO tooling.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Search engines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="s"&gt;Googlebot, Bingbot, YandexBot, Baiduspider,&lt;/span&gt;
                  &lt;span class="s"&gt;DuckDuckBot, Applebot&lt;/span&gt;
&lt;span class="na"&gt;Social&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;           &lt;span class="s"&gt;Facebook, LinkedIn, Twitter, Pinterest&lt;/span&gt;
&lt;span class="na"&gt;Monitoring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;       &lt;span class="s"&gt;Pingdom, UptimeRobot, StatusCake, Datadog&lt;/span&gt;
&lt;span class="na"&gt;SEO tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;        &lt;span class="s"&gt;Ahrefs, SEMrush, Moz, Majestic&lt;/span&gt;
&lt;span class="na"&gt;Feed readers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;     &lt;span class="s"&gt;Feedly, NewsBlur&lt;/span&gt;
&lt;span class="na"&gt;AI crawlers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;      &lt;span class="s"&gt;GPTBot, ClaudeBot, PerplexityBot (optional blocking)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For verifiable bots, the plugin does forward-confirmed reverse DNS:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Look up the requesting IP's hostname via reverse DNS&lt;/li&gt;
&lt;li&gt;Check the hostname ends with a verified domain (&lt;code&gt;.googlebot.com&lt;/code&gt;, &lt;code&gt;.google.com&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Forward-resolve that hostname back to an IP&lt;/li&gt;
&lt;li&gt;Confirm it matches the original requesting IP&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;A fake Googlebot scores +80 — an instant block&lt;/strong&gt; — 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.&lt;/p&gt;

&lt;p&gt;This matters more than it sounds: matching &lt;code&gt;Googlebot&lt;/code&gt; in a User-Agent string and allowing it is a bypass, not an allowlist. Anyone can send that string.&lt;/p&gt;

&lt;h2&gt;
  
  
  WooCommerce: carding defense
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checkout velocity limiting&lt;/strong&gt; — 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Card testing pattern detection&lt;/strong&gt; — 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.&lt;/p&gt;

&lt;p&gt;Compatible with classic checkout and WooCommerce Blocks, and declares HPOS (High-Performance Order Storage) compatibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture decisions worth calling out
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No external dependencies for core protection.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Additive scoring over binary decisions.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Daily rotating honeypot names.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HMAC-signed challenges instead of stored ones.&lt;/strong&gt; The signature itself proves the challenge is legitimate and unmodified — no database writes, no storage-exhaustion vector.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting started
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp plugin &lt;span class="nb"&gt;install &lt;/span&gt;webdecoy &lt;span class="nt"&gt;--activate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or: &lt;strong&gt;Plugins → Add New → search "WebDecoy" → Install → Activate&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;How do you handle bot scoring — hard rules or weighted signals? Curious what thresholds other people have landed on.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://webdecoy.com/blog/how-webdecoy-wordpress-plugin-detects-bots/" rel="noopener noreferrer"&gt;webdecoy.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related reading:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/proof-of-work-captcha-hashcash-stop-bots/" rel="noopener noreferrer"&gt;Proof-of-Work CAPTCHAs with Hashcash&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/headless-browser-detection-playwright-puppeteer-selenium/" rel="noopener noreferrer"&gt;Headless Browser Detection: Playwright, Puppeteer, Selenium&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/mitre-attack-honeypot-mapping-threat-detection/" rel="noopener noreferrer"&gt;Mapping Honeypot Detections to MITRE ATT&amp;amp;CK&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Bot Detection False Positives: How to Actually Test Accuracy</title>
      <dc:creator>webdecoy</dc:creator>
      <pubDate>Sun, 13 Sep 2026 15:28:21 +0000</pubDate>
      <link>https://dev.to/webdecoy/bot-detection-false-positives-how-to-actually-test-accuracy-d1o</link>
      <guid>https://dev.to/webdecoy/bot-detection-false-positives-how-to-actually-test-accuracy-d1o</guid>
      <description>&lt;p&gt;The fastest way to lose confidence in bot protection is not to miss a bot. &lt;strong&gt;It is to block a real customer.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A missed scraper costs bandwidth or content. A blocked customer costs a sale, a support escalation, and trust that took months to earn. Which is why a bot detection benchmark cannot stop at a single accuracy number. It has to answer a harder question: &lt;em&gt;what happens to real people when this policy leaves the dashboard and starts controlling traffic?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A false positive is a business event
&lt;/h2&gt;

&lt;p&gt;In bot detection, a false positive is legitimate traffic classified as automated. The technical label matters; the consequence matters more. The same wrong classification produces very different outcomes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;th&gt;Possible false-positive cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Public article&lt;/td&gt;
&lt;td&gt;One page view is challenged or delayed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Login&lt;/td&gt;
&lt;td&gt;A customer cannot reach their account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Password reset&lt;/td&gt;
&lt;td&gt;A locked-out user cannot recover access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checkout&lt;/td&gt;
&lt;td&gt;Revenue is interrupted at the point of purchase&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public API&lt;/td&gt;
&lt;td&gt;A partner integration begins failing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Account creation&lt;/td&gt;
&lt;td&gt;A legitimate prospect cannot start a trial&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;An aggregate false-positive rate hides these differences.&lt;/strong&gt; A vendor can report a low global rate while causing concentrated damage on one browser, one mobile network, or one high-value route.&lt;/p&gt;

&lt;p&gt;So the useful question is not "what is the false-positive rate?" It is: &lt;em&gt;how many known-human sessions did this rule challenge or block on each protected route, and what happened next?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Accuracy is usually the wrong headline metric
&lt;/h2&gt;

&lt;p&gt;Bot traffic is a class-imbalanced problem. Most requests on a customer-facing app are legitimate; the attacks worth stopping are a small slice. In that setting &lt;strong&gt;a large accuracy percentage can describe a weak detector.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Suppose a site receives 100,000 requests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;99,000 are legitimate&lt;/li&gt;
&lt;li&gt;1,000 are automated attacks&lt;/li&gt;
&lt;li&gt;The detector catches 900 attacks&lt;/li&gt;
&lt;li&gt;It also flags 200 legitimate requests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The detector is &lt;strong&gt;99.7% accurate.&lt;/strong&gt; That sounds excellent. It is also blocking or challenging 200 real requests, missing 100 attacks, and producing a bot verdict that is &lt;strong&gt;wrong almost one time in six.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Use a small set of metrics together instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Precision           = true bot detections / all bot detections
Recall              = true bot detections / all actual bot attempts
False-positive rate = false bot detections / all known-human requests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the example: precision 81.8%, recall 90%, false-positive rate ~0.2%. At one million legitimate requests per day, &lt;strong&gt;that last number is about 2,000 customer requests per day receiving the wrong treatment.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://developers.google.com/machine-learning/crash-course/classification/accuracy-precision-recall" rel="noopener noreferrer"&gt;Google's classification metrics guide&lt;/a&gt; makes the tradeoff clear: changing a threshold changes all three. There is no threshold you can evaluate independently of the cost of each kind of mistake.&lt;/p&gt;

&lt;p&gt;Which usually leads to three operating goals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hard blocks favor precision.&lt;/strong&gt; The evidence should be strong enough that a human is very unlikely to match it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Challenges balance precision and recall.&lt;/strong&gt; They provide a recovery path for ambiguous traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring favors recall.&lt;/strong&gt; A broad signal is fine when a person or later rule reviews it before enforcement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;One score should not control all three actions.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Build a benchmark that resembles production
&lt;/h2&gt;

&lt;p&gt;A clean lab set with one Chrome version and a few obvious Selenium scripts proves the code runs. It does not establish that the detector is safe for customers. Build from three label groups.&lt;/p&gt;

&lt;h3&gt;
  
  
  Known-human traffic
&lt;/h3&gt;

&lt;p&gt;Strong human labels come from successfully authenticated sessions, completed purchases that were not reversed, support-confirmed sessions, or employees following a controlled test plan. None is perfect alone — the point is traffic with &lt;em&gt;independent evidence&lt;/em&gt; that a real person completed a meaningful action.&lt;/p&gt;

&lt;p&gt;Keep the sample representative across:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Desktop and mobile browsers&lt;/li&gt;
&lt;li&gt;Older devices and slow connections&lt;/li&gt;
&lt;li&gt;Corporate networks, universities, and carrier-grade NAT&lt;/li&gt;
&lt;li&gt;VPNs and privacy tools your customers actually use&lt;/li&gt;
&lt;li&gt;Assistive technology and keyboard-only navigation&lt;/li&gt;
&lt;li&gt;Logged-in customers, anonymous visitors, and partner users&lt;/li&gt;
&lt;li&gt;Every route where the policy may eventually enforce&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;If the known-human set contains only employees on recent MacBooks, the benchmark is measuring employee laptops, not customers.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Known automation
&lt;/h3&gt;

&lt;p&gt;Run controlled clients with Playwright, Puppeteer, Selenium, curl, and any stack relevant to your app. Include slow bots, distributed low-volume clients, headless browsers, and scripts carrying realistic headers.&lt;/p&gt;

&lt;p&gt;Trusted automation belongs in the set too. Search crawlers, uptime monitors, accessibility scanners, payment callbacks, and partner integrations are automated — that does not make them hostile.&lt;/p&gt;

&lt;h3&gt;
  
  
  Unknown traffic
&lt;/h3&gt;

&lt;p&gt;Leave genuinely ambiguous traffic labeled unknown. &lt;strong&gt;Do not call every session that failed to convert a bot, and do not call every session that passed a JavaScript check human.&lt;/strong&gt; Those shortcuts make the detector's own assumptions part of its ground truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Freeze the policy during each test
&lt;/h2&gt;

&lt;p&gt;Record the exact configuration behind every verdict: engine version, rule and threshold version, signals that fired, score and proposed action, route group, session identifier, timestamp, allowlist decision.&lt;/p&gt;

&lt;p&gt;If thresholds change halfway through a test without a version marker, your final precision number combines two different systems.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"policy_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"checkout-2026-09-01.1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"route_group"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"checkout"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"would_action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"challenge"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"signals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"headless_mismatch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"velocity_anomaly"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"identity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"unverified"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"outcome"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"purchase_completed"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The important field is &lt;code&gt;outcome&lt;/code&gt;.&lt;/strong&gt; Without it you can count detections but cannot tell whether a proposed action would have interrupted a customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start in shadow mode
&lt;/h2&gt;

&lt;p&gt;Shadow mode evaluates every request but does not change the response. A request that would have been challenged gets the normal page; a request that would have been blocked reaches the application. The proposed action and its evidence are logged.&lt;/p&gt;

&lt;p&gt;This is the safest place to tune thresholds, because the detector sees real traffic while mistakes stay observable rather than customer-facing. For every route group, answer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;How many sessions would have been allowed, challenged, or blocked?&lt;/li&gt;
&lt;li&gt;How many would-block sessions later logged in, purchased, submitted a valid form, or called an authenticated API?&lt;/li&gt;
&lt;li&gt;Which rules contribute most of the false-positive candidates?&lt;/li&gt;
&lt;li&gt;Are errors concentrated by browser, device, geography, ASN, customer, or integration?&lt;/li&gt;
&lt;li&gt;How much attack traffic would each threshold miss?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Don't declare victory after a quiet afternoon. The sample should span weekday and weekend behavior, billing cycles, product launches, and campaigns.&lt;/p&gt;

&lt;h3&gt;
  
  
  The rule of three
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The absence of an observed false positive is not proof the true rate is zero.&lt;/strong&gt; A useful rough check: if a test observes zero errors in &lt;code&gt;N&lt;/code&gt; independent known-human sessions, the upper edge of a rough 95% confidence interval is about &lt;code&gt;3/N&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Zero errors in 1,000 sessions only supports a rate below roughly &lt;strong&gt;0.3%&lt;/strong&gt;. Zero errors in 100,000 sessions supports a much tighter claim. Report counts beside rates so a reader can tell whether "100% recall" means 2 of 2 attacks or 20,000 of 20,000.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test low-volume attacks without fooling yourself
&lt;/h2&gt;

&lt;p&gt;A credential stuffer sending two attempts per IP per day never creates an obvious spike. A scraper taking one page every few minutes blends into human traffic. If the positive class holds only a handful of confirmed attacks, one mislabeled session swings precision dramatically.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Extend the observation window.&lt;/strong&gt; Collect enough normal traffic to see rare customer conditions, and enough attack traffic to be more than a one-day anecdote.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replay known attacks.&lt;/strong&gt; Recorded sequences let you compare policy versions against identical inputs. Keep replay results separate from live ones — a recording can't reproduce every timing and network condition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run controlled red-team traffic.&lt;/strong&gt; Throttle your own automation to the rate a real attacker would use. Use realistic sessions and route order instead of hammering one endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure evidence, not just volume.&lt;/strong&gt; A low request rate doesn't erase other evidence: failed identity verification, a composite fingerprint reused across accounts, a decoy link followed, an impossible field submitted, machine-like workflow consistency. &lt;strong&gt;Rate should be one signal, not the whole detector.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Verify good bots before you allow them
&lt;/h2&gt;

&lt;p&gt;A crawler allowlist reduces false positives only if it &lt;em&gt;verifies identity&lt;/em&gt;. Matching &lt;code&gt;Googlebot&lt;/code&gt; in a User-Agent creates a bypass, because any client can send the same text — &lt;a href="https://developers.google.com/search/docs/crawling-indexing/googlebot" rel="noopener noreferrer"&gt;Google's own documentation&lt;/a&gt; warns the User-Agent is commonly spoofed and recommends verifying via published IP ranges or reverse DNS with forward confirmation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Declared identity
    -&amp;gt; verify source or signature
        -&amp;gt; verified:    apply the crawler or partner policy
        -&amp;gt; failed:      treat as impersonation evidence
        -&amp;gt; unavailable: keep unverified, avoid claiming certainty
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't turn a failed lookup into an automatic block if the source data may be stale. Record &lt;em&gt;why&lt;/em&gt; verification failed and choose a route-appropriate fallback — public content can often fail open, sensitive APIs may require a service token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shared IP addresses break simple enforcement
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;An IP address is a network location, not a person.&lt;/strong&gt; One address may represent an office, university, hotel, mobile carrier, VPN exit, or large customer integration. One abusive client behind that address does not make every other client hostile.&lt;/p&gt;

&lt;p&gt;This is exactly why an IP-only block looks accurate in a lab and fails in production: the test environment assigns one address per client, while production puts thousands of unrelated sessions behind one egress point.&lt;/p&gt;

&lt;p&gt;Treat IP reputation and request rate as &lt;em&gt;context&lt;/em&gt;. Correlate with session evidence, authentication state, route, TLS and browser characteristics, and behavior. When uncertainty remains, challenge the session rather than blocking the address.&lt;/p&gt;

&lt;p&gt;Test rate limits from a shared-network simulator: send legitimate traffic from many independent sessions through one source address, then add one abusive session. The desired result is not "the attack stopped." It is &lt;strong&gt;"the attack stopped while the other sessions continued."&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Move from shadow mode to a canary
&lt;/h2&gt;

&lt;p&gt;Once shadow results meet the route's safety threshold, enforce on a small, stable cohort. Assignment should be sticky by session or account so the same visitor doesn't bounce between control and enforcement on every request.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Shadow everything.&lt;/strong&gt; Collect proposed actions and business outcomes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canary challenges.&lt;/strong&gt; Challenge a small percentage of ambiguous sessions on one route group.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforce high-confidence evidence.&lt;/strong&gt; Block deterministic abuse, or sessions that repeatedly fail the recovery path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expand by route.&lt;/strong&gt; Increase exposure only after guardrails stay healthy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a control group.&lt;/strong&gt; A small untreated cohort keeps conversion and support impact measurable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Start with an action that can recover.&lt;/strong&gt; A challenge lets a misclassified human prove the detector wrong and continue. A hard block provides no such information unless the customer opens a ticket.&lt;/p&gt;

&lt;p&gt;Define rollback triggers &lt;em&gt;before&lt;/em&gt; the canary begins:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Login success falls beyond the agreed tolerance&lt;/li&gt;
&lt;li&gt;Checkout completion drops relative to control&lt;/li&gt;
&lt;li&gt;Challenge abandonment rises for one browser or device class&lt;/li&gt;
&lt;li&gt;Support contacts mention access failures&lt;/li&gt;
&lt;li&gt;A major customer or partner appears in the would-block cohort&lt;/li&gt;
&lt;li&gt;Latency exceeds the route's budget&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a trigger fires, return the cohort to monitor mode, preserve the evidence, and investigate the contributing rule. &lt;strong&gt;A rollback is a successful safety mechanism, not a failed launch.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure conversion impact directly
&lt;/h2&gt;

&lt;p&gt;Security metrics cannot tell you whether customers are being harmed. Join the enforcement decision to product outcomes using a privacy-conscious session or account key.&lt;/p&gt;

&lt;p&gt;For a trial signup flow: landing-to-signup-start rate, signup-start to account-created rate, challenge pass and abandonment rates, time to complete the form, validation and retry errors, support requests about access.&lt;/p&gt;

&lt;p&gt;Compare the canary with its &lt;strong&gt;concurrent&lt;/strong&gt; control group. Do not compare launch week against last month's average if traffic source, promotions, device mix, or seasonality changed at the same time.&lt;/p&gt;

&lt;p&gt;And segment the results. &lt;strong&gt;An overall conversion rate can stay flat while Safari users, one mobile carrier, or a single enterprise customer's corporate proxy experiences a serious regression.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Set thresholds from cost, not confidence theater
&lt;/h2&gt;

&lt;p&gt;A score of 90 is not automatically safe to block. It is only meaningful if you know what generated it, how that evidence performed on representative traffic, and what a mistake costs on the current route.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Evidence standard&lt;/th&gt;
&lt;th&gt;Typical action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Broad anomaly&lt;/td&gt;
&lt;td&gt;Useful for investigation, weak identity&lt;/td&gt;
&lt;td&gt;Log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Several independent suspicious signals&lt;/td&gt;
&lt;td&gt;Likely automation, meaningful uncertainty&lt;/td&gt;
&lt;td&gt;Challenge or rate-limit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verified trusted automation&lt;/td&gt;
&lt;td&gt;Proven operator or partner identity&lt;/td&gt;
&lt;td&gt;Allow under explicit policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deterministic abuse evidence&lt;/td&gt;
&lt;td&gt;Decoy interaction, valid attack signature, repeated failed proof&lt;/td&gt;
&lt;td&gt;Block with expiry&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Review thresholds after browser releases, mobile app updates, WAF changes, major customer onboarding, and attacker shifts. &lt;strong&gt;A benchmark is not a certificate that lasts forever.&lt;/strong&gt; It is a repeatable process for finding regressions before customers do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production readiness checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Known-human traffic covers important browsers, devices, networks, and customers&lt;/li&gt;
&lt;li&gt;[ ] Known automation includes trusted crawlers, partners, monitors, and hostile test clients&lt;/li&gt;
&lt;li&gt;[ ] Unknown traffic remains unknown rather than forced into a convenient label&lt;/li&gt;
&lt;li&gt;[ ] Every verdict records the policy version and contributing evidence&lt;/li&gt;
&lt;li&gt;[ ] Precision, recall, and false-positive rate are reported together&lt;/li&gt;
&lt;li&gt;[ ] Metrics are broken down by route and customer-impact level&lt;/li&gt;
&lt;li&gt;[ ] Shared-IP and corporate-proxy scenarios are included&lt;/li&gt;
&lt;li&gt;[ ] Trusted bots are verified rather than matched by User-Agent alone&lt;/li&gt;
&lt;li&gt;[ ] Shadow mode connects would-block decisions to product outcomes&lt;/li&gt;
&lt;li&gt;[ ] Canary and control cohorts are stable and comparable&lt;/li&gt;
&lt;li&gt;[ ] Challenges provide a recovery path for ambiguous traffic&lt;/li&gt;
&lt;li&gt;[ ] Hard blocks expire and can be reversed quickly&lt;/li&gt;
&lt;li&gt;[ ] Rollback triggers are written before enforcement starts&lt;/li&gt;
&lt;li&gt;[ ] Conversion, support, and latency guardrails are monitored&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If several answers are no, keep the system in shadow mode. &lt;strong&gt;More traffic is not going to make an unmeasured policy safer.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The benchmark is the product
&lt;/h2&gt;

&lt;p&gt;Bot detection accuracy is not one percentage in a sales deck. It is a body of evidence showing what the system catches, what it misses, which real users resemble automation, and what happens when a decision becomes an action.&lt;/p&gt;

&lt;p&gt;The safest rollout is deliberately uneventful. Observe first. Label carefully. Verify trusted automation. Challenge uncertainty. Block strong evidence. Measure the customer journey at every step.&lt;/p&gt;

&lt;p&gt;That process feels slower than turning on a global block rule. It is much faster than discovering your false-positive rate through lost checkouts and angry customers.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Has anyone here actually run a shadow-mode rollout end to end? Curious what your would-block cohort looked like the first week — ours is always more embarrassing than expected.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://webdecoy.com/blog/bot-detection-false-positives-testing-benchmark/" rel="noopener noreferrer"&gt;webdecoy.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related reading:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/browser-fingerprinting-2026-what-still-works/" rel="noopener noreferrer"&gt;Browser Fingerprinting 2026: What Still Works&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/headless-browser-detection-playwright-puppeteer-selenium/" rel="noopener noreferrer"&gt;Headless Browser Detection: Playwright, Puppeteer, Selenium&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>testing</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>AI Agent Authentication in 2026: Web Bot Auth, ARD &amp; OAuth</title>
      <dc:creator>webdecoy</dc:creator>
      <pubDate>Sun, 13 Sep 2026 15:26:40 +0000</pubDate>
      <link>https://dev.to/webdecoy/ai-agent-authentication-in-2026-web-bot-auth-ard-oauth-247</link>
      <guid>https://dev.to/webdecoy/ai-agent-authentication-in-2026-web-bot-auth-ard-oauth-247</guid>
      <description>&lt;p&gt;AI agent authentication is not one protocol. &lt;strong&gt;It is a stack.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An agent may need to discover a tool, prove which workload is running, authenticate an HTTP request, show that a user delegated authority, and leave enough evidence to reconstruct the action later. ARD, workload identity, Web Bot Auth, and OAuth solve &lt;em&gt;different parts&lt;/em&gt; of that sequence. Treating any one of them as the whole answer creates an identity gap.&lt;/p&gt;

&lt;p&gt;That distinction got sharper in 2026. Google announced the open &lt;a href="https://developers.googleblog.com/en/announcing-the-agentic-resource-discovery-specification/" rel="noopener noreferrer"&gt;Agentic Resource Discovery specification&lt;/a&gt; for finding and verifying agentic capabilities, and a separate &lt;a href="https://datatracker.ietf.org/doc/draft-klrc-aiagent-auth/" rel="noopener noreferrer"&gt;IETF Internet-Draft on AI agent authentication&lt;/a&gt; proposed an architecture for agent credentials, delegated user authority, workload identity, authorization, and audit trails.&lt;/p&gt;

&lt;p&gt;Neither replaces OAuth. Neither makes a signed bot trustworthy. Together they show what a serious agent identity architecture has to look like.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four layers, four questions
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;The question it answers&lt;/th&gt;
&lt;th&gt;Typical mechanism&lt;/th&gt;
&lt;th&gt;What it does &lt;strong&gt;not&lt;/strong&gt; prove&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Discovery&lt;/td&gt;
&lt;td&gt;Where is the capability, and who published its metadata?&lt;/td&gt;
&lt;td&gt;ARD catalogs, registries, trust metadata&lt;/td&gt;
&lt;td&gt;That the caller is allowed to invoke it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workload identity&lt;/td&gt;
&lt;td&gt;Which running software workload is this?&lt;/td&gt;
&lt;td&gt;WIMSE credentials, SPIFFE IDs and SVIDs, mTLS&lt;/td&gt;
&lt;td&gt;Which user delegated authority&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request authentication&lt;/td&gt;
&lt;td&gt;Did this HTTP request come from the claimed automated client?&lt;/td&gt;
&lt;td&gt;Web Bot Auth, HTTP Message Signatures, mTLS&lt;/td&gt;
&lt;td&gt;That the requested action is permitted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delegated authorization&lt;/td&gt;
&lt;td&gt;What may the agent do, for which audience, on whose behalf?&lt;/td&gt;
&lt;td&gt;OAuth access tokens, token exchange, transaction tokens&lt;/td&gt;
&lt;td&gt;That the agent will behave safely&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There is a fifth layer underneath all four: &lt;strong&gt;observability.&lt;/strong&gt; If an operator cannot connect the discovery result, workload credential, user delegation, authorization decision, tool call, and final side effect, the system is not meaningfully auditable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why API keys and user-agent strings aren't enough
&lt;/h2&gt;

&lt;p&gt;Traditional web automation has two identity mechanisms: a &lt;code&gt;User-Agent&lt;/code&gt; header that names the bot, and an API key that grants access.&lt;/p&gt;

&lt;p&gt;The first is a claim anyone can copy. The second is usually &lt;strong&gt;both an identity credential and a bearer permission compressed into one long-lived secret.&lt;/strong&gt; If that key leaks from a log, environment variable, container image, or agent transcript, whoever holds it inherits its authority.&lt;/p&gt;

&lt;p&gt;AI agents make this worse, because one action involves several distinct principals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The agent operator&lt;/strong&gt; — owns or deploys the software.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The running agent workload&lt;/strong&gt; — needs its own stable identity and credentials.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The delegating user or system&lt;/strong&gt; — whose authority the agent may be exercising.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The resource owner&lt;/strong&gt; — decides what that combination may do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Collapsing those into one API key destroys the information an authorization engine needs. It also produces an audit record that says only &lt;em&gt;"the key did it"&lt;/em&gt; — not which workload ran, who authorized it, what constraints applied, or where authority changed hands.&lt;/p&gt;

&lt;p&gt;The July 2026 IETF draft starts from a useful premise: &lt;strong&gt;agents are workloads.&lt;/strong&gt; They should receive cryptographic credentials at runtime, authenticate as themselves, carry delegated authority separately, and preserve both identities through the call chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: ARD discovers capabilities before invocation
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://agenticresourcediscovery.org/" rel="noopener noreferrer"&gt;Agentic Resource Discovery&lt;/a&gt; addresses a problem that appears &lt;em&gt;before&lt;/em&gt; authentication: how does an agent find the right tool, API, MCP server, A2A agent, or nested catalog without relying on a closed directory or hard-coded endpoint?&lt;/p&gt;

&lt;p&gt;ARD uses domain-hosted catalogs and federated discovery. A publisher describes resources and attaches trust metadata; a consumer discovers a candidate, verifies the published information, then connects through the resource's native protocol.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The boundary matters.&lt;/strong&gt; ARD is deliberately pre-invocation infrastructure. It can answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which domain claims this capability?&lt;/li&gt;
&lt;li&gt;Where is its endpoint and protocol description?&lt;/li&gt;
&lt;li&gt;What trust material did the publisher provide?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It cannot answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is this calling process the agent it claims to be?&lt;/li&gt;
&lt;li&gt;Did a user authorize this transaction?&lt;/li&gt;
&lt;li&gt;Is the requested action within policy?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put differently: &lt;strong&gt;verified discovery metadata is not a runtime access token.&lt;/strong&gt; It establishes a trusted starting point; the resource must still authenticate and authorize the caller.&lt;/p&gt;

&lt;p&gt;ARD is still evolving — the &lt;a href="https://github.com/ards-project/ard-spec" rel="noopener noreferrer"&gt;spec repository&lt;/a&gt; described v0.91 as an evolving specification. Version your catalogs and don't treat today's fields as permanently fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: workload identity proves which agent is running
&lt;/h2&gt;

&lt;p&gt;After discovery, the caller needs a real identity. The strongest model is not "read a secret from an environment variable." It is &lt;strong&gt;"attest this runtime and issue a short-lived credential to the workload that passed attestation."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The agent has a stable identifier; its credentials are temporary and rotated. Issuance can be bound to the cluster, namespace, service account, image, execution environment, or deployment policy.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://spiffe.io/docs/latest/spiffe-about/overview/" rel="noopener noreferrer"&gt;SPIFFE&lt;/a&gt; is the mature example: a workload receives a SPIFFE ID and a short-lived SVID (X.509 or JWT) through the Workload API, then uses that identity for mTLS or application-level auth without shipping a long-lived secret beside the code.&lt;/p&gt;

&lt;p&gt;The architectural requirements that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Give the agent a stable workload identifier.&lt;/li&gt;
&lt;li&gt;Provision primary credentials at runtime.&lt;/li&gt;
&lt;li&gt;Prefer short-lived, automatically rotated credentials.&lt;/li&gt;
&lt;li&gt;Keep credentials out of source code, images, prompts, and static config.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Authenticate the agent independently from the user it represents.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is useful even when no human is involved. A scheduled research agent or autonomous monitoring service still needs its own identity so policy can distinguish it from every other workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: Web Bot Auth proves an automated HTTP caller
&lt;/h2&gt;

&lt;p&gt;Workload identity fits naturally inside an organization. The open web has a different problem: a site receives an HTTP request from a crawler it does not operate. The request claims a name; the origin needs evidence the claim belongs to the operator.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth-architecture/" rel="noopener noreferrer"&gt;Web Bot Auth&lt;/a&gt; profiles &lt;a href="https://www.rfc-editor.org/rfc/rfc9421" rel="noopener noreferrer"&gt;RFC 9421 HTTP Message Signatures&lt;/a&gt; so automated clients sign request components and servers verify using public keys associated with the operator.&lt;/p&gt;

&lt;p&gt;A valid signature proves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The request was signed by the holder of the corresponding private key.&lt;/li&gt;
&lt;li&gt;Protected components were not changed after signing.&lt;/li&gt;
&lt;li&gt;The signature was created within its validity window.&lt;/li&gt;
&lt;li&gt;The identity claim ties to an operator-controlled key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; prove:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The agent is benevolent.&lt;/li&gt;
&lt;li&gt;The operator has a business relationship with the site.&lt;/li&gt;
&lt;li&gt;A user consented to the requested action.&lt;/li&gt;
&lt;li&gt;The request should bypass rate limits or bot controls.&lt;/li&gt;
&lt;li&gt;The signer is entitled to read, purchase, modify, or delete anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last distinction is the most important one in this whole article: &lt;strong&gt;authentication is evidence for an authorization decision, not the decision itself.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A practical note on deployment: &lt;strong&gt;most automated traffic is still unsigned.&lt;/strong&gt; Web Bot Auth upgrades a spoofable user-agent claim into verifiable request identity where it's present, but if you build a policy that assumes signatures, you will be building for a tiny slice of your actual traffic. Design for graceful fallback — operator-published IP ranges and forward-confirmed reverse DNS where available, and treat a bare user-agent string as &lt;em&gt;unproven&lt;/em&gt;, not as identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: OAuth carries authority, including user delegation
&lt;/h2&gt;

&lt;p&gt;Once an agent is authenticated, a resource still needs to know what it may do. Two materially different cases:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The agent acts on its own authority.&lt;/strong&gt; It obtains a narrowly scoped access token through a machine-to-machine grant, authenticating to the authorization server with its &lt;em&gt;workload credential&lt;/em&gt; — not a static, long-lived client secret. The resulting token should be short lived, audience-restricted, minimally scoped, bound to the authenticated client where supported, and revocable without rebuilding the agent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The agent acts for a user.&lt;/strong&gt; Now the system must preserve two identities: the agent as the OAuth client, and the delegating principal as the subject. The user grants authority through an interactive flow; the agent separately authenticates with its workload credential.&lt;/p&gt;

&lt;p&gt;The resource should be able to answer both:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which agent exercised the authority?&lt;/li&gt;
&lt;li&gt;Which user or system delegated it?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That separation limits the confused-deputy problem, and it prevents an audit record from falsely attributing an automated action directly to a human.&lt;/p&gt;

&lt;p&gt;For multi-service workflows, &lt;a href="https://www.rfc-editor.org/rfc/rfc8693" rel="noopener noreferrer"&gt;OAuth 2.0 Token Exchange (RFC 8693)&lt;/a&gt; lets a security token service swap one token for another with a different audience or reduced authority. &lt;strong&gt;Every hop should narrow or preserve authority; no downstream tool should silently gain a broader token than the initiating agent received.&lt;/strong&gt; Current OAuth security guidance is consolidated in &lt;a href="https://www.rfc-editor.org/rfc/rfc9700" rel="noopener noreferrer"&gt;RFC 9700&lt;/a&gt; — follow it rather than reviving weaker historical patterns because the client happens to be autonomous.&lt;/p&gt;

&lt;h2&gt;
  
  
  An end-to-end flow
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Discover.&lt;/strong&gt; Query an ARD catalog; receive a candidate capability, endpoint, protocol description, trust metadata.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify discovery.&lt;/strong&gt; Validate the publisher before connecting. Discovery results are untrusted input until verified.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attest the workload.&lt;/strong&gt; The runtime proves where and how the agent is running; the identity system issues a short-lived credential bound to its stable identifier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authenticate the connection or request.&lt;/strong&gt; mTLS or a WIMSE proof inside a trust domain; Web Bot Auth at a public website boundary. Bind the credential to the actual request or channel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Obtain authority.&lt;/strong&gt; An OAuth token for the agent's own authority, or for explicitly delegated user authority — with the two kept distinguishable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorize at the resource.&lt;/strong&gt; Evaluate agent, subject, audience, scope, action, tenant, risk, and policy. A valid credential is necessary evidence, not an automatic allow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Downscope each hop.&lt;/strong&gt; Token exchange gives downstream tools only what they need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Record and monitor.&lt;/strong&gt; Logs connect agent identifier, delegating subject, token identifier, policy decision, tool call, and side effect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revoke independently.&lt;/strong&gt; Workload, key, token, user grant, and discovered resource each revocable on their own.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Failure modes this architecture should stop
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A valid identity becomes an allowlist bypass.&lt;/strong&gt; Signing tells you who holds a key. It does not make every request from that identity safe. Keep behavioral controls, rate limits, route policy, and response-level data authorization in place for verified agents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The user and agent collapse into one subject.&lt;/strong&gt; If a downstream service sees only the user, it cannot tell whether the user acted directly or an agent acted for them. If it sees only the agent, it cannot enforce user-specific consent. Carry both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A bearer token gains power as it moves downstream.&lt;/strong&gt; Don't forward the broad original token through every tool call. Exchange or downscope per audience and action.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Discovery metadata is trusted like an access decision.&lt;/strong&gt; Catalog content can point an agent at an attacker-controlled endpoint. Verify publisher trust metadata, constrain redirects and egress, apply normal SSRF defenses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Static secrets are cloned with the agent.&lt;/strong&gt; If every replica shares one API key, you cannot distinguish instances or revoke one compromised runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The audit trail loses authorization context.&lt;/strong&gt; Logging only the final API call is not enough. Preserve agent identity, delegating principal, token audience and scope, policy version, decision, and result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Assign every agent workload a stable identifier.&lt;/li&gt;
&lt;li&gt;Issue credentials at runtime after workload attestation.&lt;/li&gt;
&lt;li&gt;Make credentials short lived and automatically rotated.&lt;/li&gt;
&lt;li&gt;Never bake API keys or OAuth client secrets into agent images, code, or prompts.&lt;/li&gt;
&lt;li&gt;Keep the agent's identity separate from the user's delegated identity.&lt;/li&gt;
&lt;li&gt;Use OAuth scopes, audience restrictions, and expiry as real policy boundaries.&lt;/li&gt;
&lt;li&gt;Downscope authority at each tool or service hop.&lt;/li&gt;
&lt;li&gt;Verify HTTP signatures over the components that matter, and enforce replay bounds.&lt;/li&gt;
&lt;li&gt;Treat discovery results as untrusted until trust metadata is verified.&lt;/li&gt;
&lt;li&gt;Do not equate a valid signature with safe behavior or permission.&lt;/li&gt;
&lt;li&gt;Log the agent, delegating subject, authorization decision, tool call, and side effect together.&lt;/li&gt;
&lt;li&gt;Design independent revocation for workload credentials, signing keys, OAuth grants, and discovered resources.&lt;/li&gt;
&lt;li&gt;Support unsigned traffic without silently upgrading a claim into verified identity.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's stable, what's still emerging
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HTTP Message Signatures&lt;/strong&gt; — standardized, RFC 9421.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OAuth Token Exchange&lt;/strong&gt; — standardized, RFC 8693. Security BCP is RFC 9700.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SPIFFE&lt;/strong&gt; — deployed workload identity infrastructure with published specs and implementations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web Bot Auth&lt;/strong&gt; — IETF Internet-Draft built on RFC 9421.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI agent authentication architecture&lt;/strong&gt; — an &lt;em&gt;individual&lt;/em&gt; Internet-Draft, not an adopted standard. &lt;code&gt;-03&lt;/code&gt; updated July 2026, work in progress.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ARD&lt;/strong&gt; — open, evolving specification. Expect iteration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That mix is normal. You do not need to wait for every draft to finalize before removing static secrets, separating agent and user identity, enforcing narrow OAuth audiences, or building complete audit trails. Those are sound security properties regardless of which emerging profile wins adoption.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ARD&lt;/strong&gt; tells an agent what exists and where to find it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workload identity&lt;/strong&gt; proves which software instance is running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web Bot Auth&lt;/strong&gt; proves who signed an automated web request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OAuth&lt;/strong&gt; says what that agent may do and on whose behalf.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability&lt;/strong&gt; proves what happened afterward.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No one layer can safely stand in for the others. The 2026 standards work matters because it stops treating "AI agent" as one magical new principal and starts decomposing it into the identities, credentials, delegation, policy, and evidence that security systems already know how to manage.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you're building agent-facing APIs right now — are you separating the agent identity from the delegating user, or is it all still one API key?&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://webdecoy.com/blog/ai-agent-authentication-web-bot-auth-ard-oauth/" rel="noopener noreferrer"&gt;webdecoy.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related reading:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/web-bot-auth-google-signed-crawlers/" rel="noopener noreferrer"&gt;Web Bot Auth: Google Now Signs Its Crawlers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/signed-agents-web-bot-auth-coming-schism/" rel="noopener noreferrer"&gt;Signed Agents and the Coming Web Identity Schism&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>oauth</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Browser Fingerprinting in 2026: What Still Works, What Doesn't</title>
      <dc:creator>webdecoy</dc:creator>
      <pubDate>Sun, 13 Sep 2026 15:24:47 +0000</pubDate>
      <link>https://dev.to/webdecoy/browser-fingerprinting-in-2026-what-still-works-what-doesnt-1hk9</link>
      <guid>https://dev.to/webdecoy/browser-fingerprinting-in-2026-what-still-works-what-doesnt-1hk9</guid>
      <description>&lt;p&gt;Browser fingerprinting still works in 2026, but the useful techniques have shifted underneath everyone. Chrome's Privacy Sandbox has frozen or removed half the signals fingerprinting libraries depended on. Firefox and Safari added noise injection and API restrictions. Brave blocks attempts outright. Meanwhile headless browser frameworks have gotten dramatically better at spoofing what remains.&lt;/p&gt;

&lt;p&gt;If you're building or maintaining a fingerprinting system, &lt;strong&gt;a lot of the advice from even two years ago is obsolete.&lt;/strong&gt; This is a technical audit of what still produces usable signal, what's been neutralized, and what emerged to replace the losses.&lt;/p&gt;

&lt;p&gt;One caveat up front: a fingerprint is not a durable cross-browser identity, and no single fingerprint should be treated as proof that a visitor is human or automated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Scoreboard
&lt;/h2&gt;

&lt;p&gt;Each technique rated on two axes — &lt;strong&gt;entropy&lt;/strong&gt; (how much identifying information it produces) and &lt;strong&gt;durability&lt;/strong&gt; (how resistant it is to spoofing and browser mitigation).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Technique&lt;/th&gt;
&lt;th&gt;Entropy&lt;/th&gt;
&lt;th&gt;Durability&lt;/th&gt;
&lt;th&gt;Status in 2026&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;User-Agent string&lt;/td&gt;
&lt;td&gt;Very low&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Dead. Frozen by Chrome 107+.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;navigator.plugins&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Dead. Returns empty array in Chrome.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;navigator.platform&lt;/td&gt;
&lt;td&gt;Very low&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Dead. Frozen to generic values.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Canvas fingerprint&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Degraded but usable. Noise in Firefox/Brave.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebGL fingerprint&lt;/td&gt;
&lt;td&gt;Medium-High&lt;/td&gt;
&lt;td&gt;Medium-High&lt;/td&gt;
&lt;td&gt;Still strong. Renderer strings remain diverse.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebGL rendering&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Works. GPU output is hard to standardize.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AudioContext&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Works. Hardware timing differences persist.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Font enumeration&lt;/td&gt;
&lt;td&gt;Low-Medium&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Declining. OS font standardization.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screen/display props&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Minimal entropy. Heavily spoofed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client Hints&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Reduced by design.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TLS fingerprint (JA4)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;High&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Very High&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Strongest signal. Cannot be spoofed from JS.&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP/2 settings&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Underutilized. Good connection-level entropy.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TCP/IP stack&lt;/td&gt;
&lt;td&gt;Low-Medium&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Niche but durable.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The trend is clear: &lt;strong&gt;JavaScript-accessible signals are eroding. Network-level signals are ascendant.&lt;/strong&gt; The most durable techniques in 2026 operate below the browser's API surface, where privacy extensions and stealth plugins can't reach them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Dead
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;User-Agent string.&lt;/strong&gt; Chrome killed it. Since Chrome 107 in late 2022 the string is frozen — the version number still increments, but OS version is pinned to &lt;code&gt;Windows NT 10.0&lt;/code&gt;, platform details are generic, and it no longer differentiates minor versions or OS builds. Firefox and Safari followed. You can distinguish Chrome from Firefox from Safari, and that's about it. For bot detection it's worse than useless, because every automation framework sets whatever string it wants.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;navigator.plugins&lt;/code&gt; / &lt;code&gt;navigator.mimeTypes&lt;/code&gt;.&lt;/strong&gt; Used to return arrays of installed plugins — a user with Flash 32.0.0.453, Java 8u281 and Chrome PDF Viewer had a distinct, slowly-changing signature. Chrome now returns a fixed generic array. Firefox the same. Remove these from any library that still checks them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;navigator.platform&lt;/code&gt;.&lt;/strong&gt; Frozen to generic values like &lt;code&gt;Win32&lt;/code&gt; regardless of actual architecture. The Client Hints replacement (&lt;code&gt;navigator.userAgentData.platform&lt;/code&gt;) is &lt;em&gt;designed&lt;/em&gt; to be lower entropy and gates detailed values behind a permission request.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Degraded but Usable
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Canvas fingerprinting
&lt;/h3&gt;

&lt;p&gt;Draw a complex scene, read back the pixel data, hash it. GPU hardware, driver versions, font rendering and anti-aliasing produce slightly different output across devices.&lt;/p&gt;

&lt;p&gt;Per-browser reality in 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chrome&lt;/strong&gt; — still consistent, device-specific output. No noise injection. Highest-fidelity target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firefox&lt;/strong&gt; — noise injection since 113 via &lt;code&gt;privacy.resistFingerprinting&lt;/code&gt;. Off by default, on in strict privacy mode and private windows. When enabled, the same device produces a different hash on every page load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safari&lt;/strong&gt; — minimal canvas protection. Still stable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brave&lt;/strong&gt; — aggressively randomizes by default. Effectively useless against Brave users.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getCanvasFingerprint&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textBaseline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;top&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;14px Arial&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#f60&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;125&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;62&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#069&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Browser fingerprint&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rgba(102, 204, 0, 0.7)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Browser fingerprint&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Geometric shapes for GPU-dependent rendering&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beginPath&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;closePath&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDataURL&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; still works on roughly 80% of browsers. Expect continued degradation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Font enumeration
&lt;/h3&gt;

&lt;p&gt;Weakened for three reasons: OS standardization (Windows 11, recent macOS and modern Linux distros ship increasingly similar default sets), web font dominance (most modern sites never trigger system font rendering), and browser restrictions (&lt;code&gt;privacy.resistFingerprinting&lt;/code&gt; returns a fixed list). Still distinguishes Windows from macOS from Linux. The days of font lists as a high-entropy identifier are over.&lt;/p&gt;

&lt;h3&gt;
  
  
  Screen and display properties
&lt;/h3&gt;

&lt;p&gt;A 1920×1080 display at 1× describes tens of millions of devices. Trivially spoofed — Playwright and Puppeteer set arbitrary viewport sizes in one line. Include in a composite, don't rely on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Still Works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  WebGL — the quiet workhorse
&lt;/h3&gt;

&lt;p&gt;Two levels. &lt;strong&gt;Parameter enumeration&lt;/strong&gt; exposes hardware and driver information:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getWebGLFingerprint&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webgl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;debugInfo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getExtension&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WEBGL_debug_renderer_info&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;vendor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VENDOR&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;renderer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RENDERER&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;unmaskedVendor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;debugInfo&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;debugInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UNMASKED_VENDOR_WEBGL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;unmaskedRenderer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;debugInfo&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;debugInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UNMASKED_RENDERER_WEBGL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;maxTextureSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MAX_TEXTURE_SIZE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;maxViewportDims&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MAX_VIEWPORT_DIMS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSupportedExtensions&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;shadingLanguageVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SHADING_LANGUAGE_VERSION&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unmasked renderer string alone carries substantial entropy — &lt;code&gt;ANGLE (NVIDIA GeForce RTX 4070 Ti Direct3D11 vs_5_0 ps_5_0)&lt;/code&gt; identifies a specific GPU model. &lt;strong&gt;Level 2&lt;/strong&gt; is render output: drawing a 3D scene and reading back pixels, with higher variability than canvas because 3D pipelines differ more across GPU architectures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it's durable:&lt;/strong&gt; browser vendors have been reluctant to restrict WebGL because doing so breaks legitimate applications — games, data visualizations, 3D product viewers, mapping. Injecting noise would break these &lt;em&gt;visibly&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For bot detection specifically, WebGL is gold.&lt;/strong&gt; Headless Chrome in a cloud VM reports the VM's virtual GPU — typically &lt;code&gt;Google SwiftShader&lt;/code&gt; or &lt;code&gt;llvmpipe&lt;/code&gt; — instantly distinguishable from any real user GPU. Even BaaS platforms that spoof this value struggle to replicate the full constellation of parameters a real GPU produces.&lt;/p&gt;

&lt;h3&gt;
  
  
  AudioContext
&lt;/h3&gt;

&lt;p&gt;Exploits hardware-dependent differences in audio signal processing. Route an &lt;code&gt;OscillatorNode&lt;/code&gt; through a &lt;code&gt;DynamicsCompressorNode&lt;/code&gt;, read the output, and you get floating-point sample values that vary with the audio hardware and driver stack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getAudioFingerprint&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OfflineAudioContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;44100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;44100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;oscillator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createOscillator&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;oscillator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;triangle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;oscillator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frequency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValueAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;compressor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDynamicsCompressor&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;compressor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValueAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;compressor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;knee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValueAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;compressor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValueAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;compressor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValueAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;compressor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;release&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValueAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;oscillator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;compressor&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;compressor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;oscillator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startRendering&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getChannelData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
      &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lower entropy than WebGL, but it's an &lt;em&gt;independent&lt;/em&gt; signal that's hard to spoof because it depends on the actual audio processing pipeline rather than a JavaScript property. Headless browsers often have no audio stack at all, or a software implementation producing output that matches no real desktop configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  TLS fingerprinting (JA4)
&lt;/h3&gt;

&lt;p&gt;The biggest shift in fingerprinting since canvas was discovered. &lt;strong&gt;TLS fingerprinting doesn't operate in JavaScript at all.&lt;/strong&gt; It analyzes the ClientHello sent during the HTTPS handshake — before page content loads, before JavaScript executes, before any browser API can be manipulated.&lt;/p&gt;

&lt;p&gt;The ClientHello contains supported cipher suites in preference order, TLS extensions and their order, supported groups, signature algorithms, and ALPN protocols. JA4 hashes these into a fingerprint identifying the TLS stack implementation. Critically: &lt;strong&gt;you cannot change your JA4 fingerprint from JavaScript.&lt;/strong&gt; It's determined by the TLS library compiled into the client.&lt;/p&gt;

&lt;p&gt;Which means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Playwright bot claiming Chrome 126 but running an older Chromium has a JA4 that doesn't match real Chrome 126.&lt;/li&gt;
&lt;li&gt;A Python &lt;code&gt;requests&lt;/code&gt; session spoofing a Chrome User-Agent has a JA4 matching &lt;code&gt;urllib3&lt;/code&gt;, not Chrome.&lt;/li&gt;
&lt;li&gt;A BaaS platform running headless Chrome in the cloud has a JA4 matching their specific Chromium build — often months behind stable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cost: it requires server-side or proxy-level access to the raw handshake. You can't do it from JavaScript. But if you control the server, or use a CDN that exposes TLS metadata (Cloudflare exposes JA3 and JA4 in firewall rules), it's the most powerful identification signal available.&lt;/p&gt;

&lt;h3&gt;
  
  
  HTTP/2 settings
&lt;/h3&gt;

&lt;p&gt;When a client opens an HTTP/2 connection it sends a SETTINGS frame — initial window size, max concurrent streams, header table size, enabled push. Different implementations choose different defaults, and like TLS it's determined by the client implementation rather than configurable from JavaScript. Underutilized, and worth adding.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Anti-Fingerprinting Landscape
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Chrome (Privacy Sandbox).&lt;/strong&gt; Surgical rather than blunt: reduce entropy from each API rather than blocking it. Frozen UA, reduced Client Hints, deprecated plugins. WebGL renderer info stays exposed because removing it would break too many sites. The goal is to make fingerprinting &lt;em&gt;less unique&lt;/em&gt;, not impossible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Firefox.&lt;/strong&gt; The most granular controls. &lt;code&gt;privacy.resistFingerprinting&lt;/code&gt; (off by default, on in Tor Browser) adds canvas noise, restricts font enumeration, normalizes screen dimensions, limits timer precision. Standard Firefox blocks known fingerprinting scripts by domain without modifying API output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Safari.&lt;/strong&gt; Pragmatic — restricts some vectors (limiting &lt;code&gt;document.fonts&lt;/code&gt;, reducing timer precision) but focuses mainly on cookie and storage partitioning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Brave.&lt;/strong&gt; The most aggressive. Randomizes canvas, blocks WebGL renderer info, adds AudioContext noise, limits fonts. The goal is to make fingerprinting actively &lt;em&gt;unreliable&lt;/em&gt;, not just lower entropy.&lt;/p&gt;

&lt;p&gt;On the other side:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Puppeteer Extra Stealth / Playwright stealth patches&lt;/strong&gt; patch &lt;code&gt;navigator.webdriver&lt;/code&gt;, spoof plugin arrays, override Chrome runtime properties. Increasingly they spoof &lt;code&gt;WEBGL_debug_renderer_info&lt;/code&gt; to report a realistic GPU instead of SwiftShader. &lt;strong&gt;None of it affects TLS or network-level signals.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser-as-a-Service (Browserbase, Hyperbrowser)&lt;/strong&gt; run real Chromium in the cloud, producing genuine fingerprints at the JavaScript level. Their weakness is TLS: the Chromium version they run often lags stable, creating a detectable mismatch between claimed UA version and actual JA4.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anti-detect browsers (Multilogin, GoLogin, Dolphin Anty)&lt;/strong&gt; are the hardest to detect, because they use modified real browser engines rather than automation frameworks. Detection requires ensemble methods — no single signal catches them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a System in 2026
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Layer 1 — network fingerprints (server-side).&lt;/strong&gt; Collect at the proxy or load balancer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TLS ClientHello   → JA4 hash
HTTP/2 SETTINGS   → settings fingerprint
TCP/IP characteristics → OS fingerprint
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the foundation because it cannot be manipulated from the client. It tells you what the client actually &lt;em&gt;is&lt;/em&gt;, regardless of what it claims.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2 — hardware fingerprints (JavaScript).&lt;/strong&gt; WebGL renderer + parameters, AudioContext output, canvas rendering. Harder to spoof than software properties because they depend on physical components and low-level driver behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3 — behavioral fingerprints (JavaScript).&lt;/strong&gt; Mouse movement patterns, scroll behavior, keystroke timing, touch events, interaction timing. Not traditional fingerprints, but the strongest bot/human discrimination available — a bot can spoof every static property perfectly and still fail here, because generating convincing human interaction at scale is an unsolved problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cross-signal coherence is the whole game
&lt;/h3&gt;

&lt;p&gt;No single technique is sufficient. The value is in the combination, and specifically in &lt;strong&gt;coherence&lt;/strong&gt;. A client claiming Chrome 126 on Windows 11 should have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a JA4 hash matching Chrome 126's TLS stack&lt;/li&gt;
&lt;li&gt;a WebGL renderer matching a real Windows GPU (not SwiftShader)&lt;/li&gt;
&lt;li&gt;AudioContext output consistent with Windows audio drivers&lt;/li&gt;
&lt;li&gt;HTTP/2 settings matching Chrome's defaults&lt;/li&gt;
&lt;li&gt;mouse movement with human-like jitter and velocity curves&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any one of these contradicts the others, something is being spoofed. &lt;strong&gt;A real browser is internally consistent. A spoofed browser almost never is&lt;/strong&gt;, because each spoofing mechanism operates independently and rarely accounts for cross-signal dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  The privacy tension
&lt;/h2&gt;

&lt;p&gt;This has been written from a security perspective, deliberately. Fingerprinting for bot detection and fingerprinting for cross-site tracking are the same technology applied to different ends.&lt;/p&gt;

&lt;p&gt;The privacy concerns are real — fingerprinting has been used to track users across sites without consent, circumventing cookie controls. The browser restrictions above are responses to documented abuse.&lt;/p&gt;

&lt;p&gt;The security case is also real. Without fingerprinting, bots are nearly undetectable. CAPTCHAs fail. Rate limiting fails. Behavioral analysis alone has a high false-positive rate. The same signals that let an ad network track you across the web are the signals that let a payment processor catch a credential-stuffing attack.&lt;/p&gt;

&lt;p&gt;This is a genuine tension, not a false dichotomy. The current trajectory — browsers restricting JavaScript-accessible signals while network-level fingerprinting becomes the primary detection vector — is a reasonable compromise. It makes cross-site tracking harder (you can't read TLS fingerprints from JavaScript) while preserving the ability of server operators to identify suspicious clients hitting their own infrastructure.&lt;/p&gt;

&lt;p&gt;Where this lands in three years is anyone's guess. For now: the toolkit is smaller than it was, the signals that remain are more durable than the ones that were lost, and the arms race shows no sign of slowing.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Anyone here still getting useful entropy out of canvas, or have you moved everything to the network layer?&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://webdecoy.com/blog/browser-fingerprinting-2026-what-still-works/" rel="noopener noreferrer"&gt;webdecoy.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related reading:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/ja4-fingerprinting-ai-scrapers-practical-guide/" rel="noopener noreferrer"&gt;JA4 Fingerprinting for AI Scraper Detection&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/headless-browser-detection-playwright-puppeteer-selenium/" rel="noopener noreferrer"&gt;Headless Browser Detection: Playwright, Puppeteer, Selenium&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/browser-as-a-service-detection-baas-ai-agents-2025/" rel="noopener noreferrer"&gt;How to Detect Browser-as-a-Service Scrapers&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Why CAPTCHAs Are Dead (And What Replaces Them in 2026)</title>
      <dc:creator>webdecoy</dc:creator>
      <pubDate>Sun, 13 Sep 2026 15:23:18 +0000</pubDate>
      <link>https://dev.to/webdecoy/why-captchas-are-dead-and-what-replaces-them-in-2026-170j</link>
      <guid>https://dev.to/webdecoy/why-captchas-are-dead-and-what-replaces-them-in-2026-170j</guid>
      <description>&lt;p&gt;There's a pattern you can watch happen on any reasonably popular site once a quarter. A team ships a new sign-up flow. They add reCAPTCHA. The bots keep coming. They upgrade to reCAPTCHA v3 invisible. The bots keep coming. They switch to hCaptcha for privacy reasons. The bots keep coming. They try Cloudflare Turnstile. The bots keep coming. Somewhere around month four, somebody writes a Slack message that reads "what if we just made it harder for &lt;em&gt;real users&lt;/em&gt;?"&lt;/p&gt;

&lt;p&gt;This is not a story about a particular CAPTCHA being broken. It's a story about &lt;strong&gt;a category that has lost its asymmetric advantage.&lt;/strong&gt; The defining property of an effective CAPTCHA — that it costs more for an attacker to solve than for a legitimate user — has been priced into oblivion. Solvers cost a fraction of a cent. Vision models do the rest for free. Real users churn.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a CAPTCHA Was Supposed to Do
&lt;/h2&gt;

&lt;p&gt;A CAPTCHA, in the 2003 sense Luis von Ahn coined, is a "Completely Automated Public Turing test to tell Computers and Humans Apart." The asymmetry depends on three properties:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The challenge is hard for current machine learning.&lt;/li&gt;
&lt;li&gt;The challenge is easy for typical humans.&lt;/li&gt;
&lt;li&gt;The cost to generate the challenge is much lower than the cost to solve it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All three were approximately true for distorted-text CAPTCHAs in 2003. &lt;strong&gt;None are true for any visual CAPTCHA in 2026.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The death of (1) is what people mean when they say CAPTCHAs are broken. The death of (2) is what people mean when they say CAPTCHAs are user-hostile. The death of (3) is the boring one that actually matters, because it's the one that breaks the economics for defenders.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solver Economy
&lt;/h2&gt;

&lt;p&gt;Here's the part that doesn't get talked about enough. &lt;strong&gt;CAPTCHAs are not defeated primarily by AI. They are defeated by markets that price AI at scale.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;2Captcha, CapSolver, AntiCaptcha and a long tail of grey-market resellers operate as commodity APIs. You send a challenge image or sitekey, you get back a token, you pay per solve. Pricing as of mid-2026, in round numbers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Challenge type&lt;/th&gt;
&lt;th&gt;Price per 1,000 solves&lt;/th&gt;
&lt;th&gt;Median solve time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;reCAPTCHA v2 (image grids)&lt;/td&gt;
&lt;td&gt;$1.50 – $3.00&lt;/td&gt;
&lt;td&gt;8 – 15 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;reCAPTCHA v3 (invisible)&lt;/td&gt;
&lt;td&gt;$1.50 – $2.50&lt;/td&gt;
&lt;td&gt;1 – 3 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hCaptcha&lt;/td&gt;
&lt;td&gt;$1.00 – $2.50&lt;/td&gt;
&lt;td&gt;8 – 12 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare Turnstile&lt;/td&gt;
&lt;td&gt;$1.50 – $3.00&lt;/td&gt;
&lt;td&gt;4 – 10 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arkose Labs FunCaptcha&lt;/td&gt;
&lt;td&gt;$3.00 – $7.00&lt;/td&gt;
&lt;td&gt;15 – 30 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio CAPTCHA&lt;/td&gt;
&lt;td&gt;$2.00 – $4.00&lt;/td&gt;
&lt;td&gt;15 – 25 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Translate that into attacker math. A credential stuffing operator running 100,000 attempts a day at a 0.3% hit rate, valuing a validated account at $20, makes &lt;strong&gt;$6,000/day in revenue against $300 in solver cost.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;CAPTCHA is a 5% line item in their cost of goods sold. It is not a deterrent. It is a tax an attacker pays without complaint while extracting value from your system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Autopsy, By Variant
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;reCAPTCHA v2 image grids.&lt;/strong&gt; Solved end-to-end by GPT-4-class vision models in 2023, and by smaller, cheaper fine-tuned models since. Solve rate against modern grids is consistently above 90% — &lt;em&gt;higher than the average human solve rate against the same puzzles.&lt;/em&gt; The audio accessibility fallback is solved by Whisper with similar reliability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;reCAPTCHA v3 invisible scoring.&lt;/strong&gt; Three concrete problems. The score is gameable — operators run checkers in real Chrome with warm Google session cookies on seasoned residential profiles, scoring 0.7–0.9. The score routinely flags legitimate users on Linux, Firefox, hardened browsers, or VPNs, often below 0.3. And every page shipping v3 is also shipping a Google tracking beacon to every visitor, which is a live regulatory issue under GDPR and US state privacy laws.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;hCaptcha.&lt;/strong&gt; Functionally similar to v2 with a privacy-first marketing posture. Solved by the same providers at similar prices with similar success rates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare Turnstile.&lt;/strong&gt; The most interesting of the modern lot, because it skips the puzzle and scores on browser environment signals. When it works, it works invisibly. When it fails, it fails opaquely. Two failure modes we see in the wild: it passes Browser-as-a-Service traffic (Browserbase, Hyperbrowser) with high frequency, because those platforms serve real Chromium on real residential IPs; and it blocks a long tail of legitimate users on hardened privacy browsers (Brave strict shields, LibreWolf, Tor) at rates that produce real conversion impact. Right shape for the future, closed implementation tied to one edge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Arkose FunCaptcha.&lt;/strong&gt; Long the holdout because of the 3D-rotation requirement. By 2025, depth-aware vision models trained on synthetic 3D renders started solving these reliably.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Shape of the Replacement
&lt;/h2&gt;

&lt;p&gt;Six independently useful components. Most production stacks combine three or four. &lt;strong&gt;None are silver bullets&lt;/strong&gt; — the point is that combining cheap signals raises attacker cost faster than any single signal does.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Behavioral biometrics
&lt;/h3&gt;

&lt;p&gt;Real humans interact with a page in characteristically messy ways. Continuous mouse trajectories with micro-tremors at 3–25 Hz from physiological hand movement. Hover, overshoot, correct. Inter-keystroke intervals that follow a log-normal distribution with dwell-time variance, rollover on common letter pairs, and real Shannon entropy.&lt;/p&gt;

&lt;p&gt;Automated browsers can replay recorded human traces — that's the obvious counter. The current state of play is that replayed traces look right at first order (mouse moves, things get clicked) but break under second-order analysis: the trace doesn't match the page layout the model is currently looking at, keystroke entropy is uniform across the corpus, the timing distribution has a different tail.&lt;/p&gt;

&lt;p&gt;Signals worth capturing, in rough order of cheapness:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mouse trajectory entropy and curvature&lt;/li&gt;
&lt;li&gt;Inter-event timing distributions (keystroke, mouse move, scroll)&lt;/li&gt;
&lt;li&gt;Field-fill order and time-on-form&lt;/li&gt;
&lt;li&gt;Pointer move events between page load and first click&lt;/li&gt;
&lt;li&gt;Touch vs mouse vs synthetic event detection (&lt;code&gt;event.isTrusted&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Scroll velocity profiles&lt;/li&gt;
&lt;li&gt;Focus and blur event sequences&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Proof-of-work
&lt;/h3&gt;

&lt;p&gt;The oldest idea in the deck, and one of the most underused. Adam Back's 1997 Hashcash proposal is the original spec: before accepting a request, require the client to find a partial SHA-256 collision against a server-issued nonce. Tunable difficulty, no interactivity, invisible to the user.&lt;/p&gt;

&lt;p&gt;The asymmetry is straightforward. &lt;strong&gt;A 200 ms PoW solve on a real phone is below the threshold of perception. The same 200 ms across 10,000 parallel sessions is 33 minutes of single-threaded compute&lt;/strong&gt;, or a real cloud bill in parallel. Worth nothing against someone targeting one account. Crippling for mass-volume operators.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;solve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;difficultyBits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;difficultyBits&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;counter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;counter&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hashInt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hashInt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;counter&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Difficulty calibration is the only interesting tuning question. Too low and it doesn't bite; too high and slow phones see a noticeable delay. We land around &lt;strong&gt;20 to 22 bits&lt;/strong&gt;, roughly 100–400 ms on a five-year-old Android and well under a second on anything modern.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Privacy Pass and Private Access Tokens
&lt;/h3&gt;

&lt;p&gt;The most promising long-term direction. A trusted attester (Apple, Google, your own service) verifies the client is a real device, then issues a blind cryptographic token the client redeems at your service. You learn the request came from an attested human-controlled device. You learn nothing else. The attester learns nothing about which sites the user visits.&lt;/p&gt;

&lt;p&gt;The catch in 2026 is coverage: well-supported on Apple platforms, partially on Cloudflare's network, effectively unsupported elsewhere. &lt;strong&gt;PAT is part of the stack, not the whole stack.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  4. TLS and HTTP/2 fingerprinting
&lt;/h3&gt;

&lt;p&gt;The layer below the browser. Every HTTP client has a TLS ClientHello with a specific cipher suite ordering, extension list, and supported groups; every HTTP/2 client has a settings frame with specific values and pseudo-header order. These vary by client library and stack version, and are very hard to spoof from a script without driving an actual browser.&lt;/p&gt;

&lt;p&gt;A POST that arrives with &lt;code&gt;User-Agent: Mozilla/5.0 ... Chrome/124&lt;/code&gt; and a JA4 fingerprint that says "Go HTTP client" is automated. Full stop. No human Chrome ever produced that combination.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Honeypot fields and decoy endpoints
&lt;/h3&gt;

&lt;p&gt;The oldest cheap trick still works for a useful slice of the threat. Two important caveats, though. Classic CSS-hidden honeypots (&lt;code&gt;display: none&lt;/code&gt;) are increasingly &lt;em&gt;invisible&lt;/em&gt; to vision-based agents that read the rendered page rather than the HTML — the agent never sees the field, so it never fills it. And accessibility tooling sometimes interacts with hidden fields, producing false positives for screen-reader users.&lt;/p&gt;

&lt;p&gt;The patterns that hold up in 2026 use DOM-tree placement (a field after the submit button), naming conventions that look real but never appear in your actual schema, or Shadow DOM containment that mainstream automation libraries don't traverse.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Server-side risk scoring
&lt;/h3&gt;

&lt;p&gt;The layer that ties everything together. Every signal above produces a feature; score requests across all of them and decide what to allow, what to challenge with a step-up, and what to silently drop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The thing to avoid is a single hardcoded "bot or not" threshold. The thing to embrace is a graduated response that maps risk score to action.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How They Combine
&lt;/h2&gt;

&lt;p&gt;A realistic 2026 stack on a high-value form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Edge
   - TLS / HTTP/2 fingerprint logged
   - Datacenter ASN gets harder PoW difficulty
   - Known-bad fingerprint clusters hard-blocked

2. Page load
   - Behavioral telemetry script attached
   - Keystroke, mouse, scroll, focus events captured
   - Honeypot fields rendered into the DOM
   - PoW challenge issued at low difficulty

3. Submit
   - Form + signed telemetry token + PoW result posted
   - Honeypot fields verified empty
   - Server computes risk score across all signals

4. Decision
   - Score below A: accept silently
   - Score in band: step up (harder PoW or soft MFA)
   - Score above B: drop with response symmetry
                    (identical status, body shape, timing)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you do not do, in any layer, is show the user a visual challenge.&lt;/p&gt;

&lt;h2&gt;
  
  
  An open-source implementation
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/WebDecoy/FCaptcha" rel="noopener noreferrer"&gt;FCaptcha&lt;/a&gt; is our open-source implementation of this stack (currently v1.37). We started it because every team that came to us asking how to replace reCAPTCHA wanted the same three things: an open-source library, a self-hostable server, and a scoring algorithm they could read and audit.&lt;/p&gt;

&lt;p&gt;What's in the box: behavioral telemetry across mouse/keystroke/scroll/focus/environment categories; keystroke cadence biometrics (dwell variance, log-normal fit, Shannon entropy, autocorrelation, rollover detection); SHA-256 proof-of-work with server-side timing validation; vision-AI detection (zero-movement click bypass, screenshot-to-API patterns, synthetic event filtering); automation detection for Playwright, Puppeteer, Selenium, Stagehand and BaaS; servers in Go, Python and Node with identical scoring semantics; no cookies, no cross-site tracking, no PII.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Run the server.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 3000:3000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;FCAPTCHA_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-secret &lt;span class="se"&gt;\&lt;/span&gt;
  ghcr.io/webdecoy/fcaptcha
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you &lt;code&gt;POST /api/*&lt;/code&gt; for verification and &lt;code&gt;GET /fcaptcha.js&lt;/code&gt; for the widget. Two deployment notes worth knowing up front: the server &lt;strong&gt;fails closed without &lt;code&gt;FCAPTCHA_SECRET&lt;/code&gt;&lt;/strong&gt; (the public dev key is in the repo, so anything signed with it can be minted by anyone), and running &lt;strong&gt;more than one replica requires &lt;code&gt;REDIS_URL&lt;/code&gt;&lt;/strong&gt; so single-use tokens are single-use across the whole deployment rather than per process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Add the widget.&lt;/strong&gt; Invisible mode auto-protects forms with no UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://your-server.com/fcaptcha.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;FCaptcha&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;serverUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://your-server.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="nx"&gt;FCaptcha&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invisible&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;siteKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;your-site-key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;autoScore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Verify on the backend.&lt;/strong&gt; A plain HTTP POST:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://your-server.com/api/token/verify&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;token_from_form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;secret&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FCAPTCHA_SECRET&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# In FCaptcha, a LOW score means the request looks human.
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;valid&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;valid&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;require_step_up&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What we don't claim
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;This does not "solve bot detection."&lt;/strong&gt; Nothing does. It raises the cost of mass-volume automated abuse to the point where commodity attackers move on and bespoke attackers leave signals you can act on. Targeted attackers with patient capital and real Chromium on real residential bandwidth will still get through. That's true of every defense in this category.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Behavioral telemetry can be replayed.&lt;/strong&gt; Open datasets of recorded human interactions exist for the explicit purpose of training automation to look human. The signals that hold up are second-order ones — does the trace match the &lt;em&gt;current&lt;/em&gt; page, does the keystroke entropy match the &lt;em&gt;current&lt;/em&gt; user's history. Harder to replay convincingly. Not impossible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proof-of-work is not free for clients.&lt;/strong&gt; A 200 ms hit is small but not zero. On a four-year-old phone with thermal throttling it can stretch to 800 ms. Tuning difficulty by device class means low-trust devices get more friction, which is its own UX cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No CAPTCHA replacement is GDPR-trivial.&lt;/strong&gt; Behavioral telemetry is biometric data under EU law, and the scoring is opaque enough that "right to explanation" obligations require thought. You will still need a privacy review.&lt;/p&gt;

&lt;h2&gt;
  
  
  A migration path
&lt;/h2&gt;

&lt;p&gt;If you're on reCAPTCHA today, the path that has worked for most teams:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Week 1&lt;/strong&gt; — Ship behavioral telemetry &lt;em&gt;alongside&lt;/em&gt; the existing CAPTCHA. Log scores, don't gate on them. Build a dashboard of score distributions for known-good and known-bad sessions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weeks 2–3&lt;/strong&gt; — Calibrate thresholds against the labeled dashboard. Find the band that cleanly separates confirmed humans from confirmed bots, and the gray middle that needs step-up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Week 4&lt;/strong&gt; — Move the CAPTCHA into step-up-only mode. Default flow is invisible; only the gray band sees a challenge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weeks 5–6&lt;/strong&gt; — Replace the CAPTCHA step-up with a higher-difficulty PoW step-up, or soft MFA for accounts that have it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Week 7&lt;/strong&gt; — Remove the CAPTCHA SDK entirely. Audit the privacy footprint reduction. Tell your conversions team.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most teams measure a conversion lift on legitimate sign-up traffic at step 3 — the moment the CAPTCHA stops gating clean sessions — and a sharper drop in confirmed bot success by step 5.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this goes next
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Agent-driven traffic from real Chromiums.&lt;/strong&gt; The Browser-as-a-Service ecosystem is industrializing exactly the population that defeats most behavioral defenses. Cross-session correlation, JA4-plus-device-fingerprint binding, and challenge interaction physics beyond first-order signals all matter more in this world.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Standardized client attestation.&lt;/strong&gt; PAT is the early form. WebAuthn-anchored device attestation, TPM-backed remote attestation, and App Attest are converging toward a future where "is this a real device controlled by a real user" is a cryptographic question rather than an inferential one. Uneven and partial today. Worth tracking.&lt;/p&gt;




&lt;p&gt;The CAPTCHA is dead. What replaces it is not one thing — it's a stack of cheap, layered signals producing a real-time score, paired with a graduated response that mostly does nothing visible to the user. That's the bar. Everything below it is theater.&lt;/p&gt;

&lt;p&gt;Either way: please stop making people click on traffic lights.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What are you running on your sign-up flow right now? Curious how many teams have actually managed to rip reCAPTCHA out versus just layering on top of it.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://webdecoy.com/blog/why-captchas-are-dead-and-what-replaces-them-in-2026/" rel="noopener noreferrer"&gt;webdecoy.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related reading:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/proof-of-work-captcha-hashcash-stop-bots/" rel="noopener noreferrer"&gt;Proof-of-Work CAPTCHAs with Hashcash&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/ja4-fingerprinting-ai-scrapers-practical-guide/" rel="noopener noreferrer"&gt;JA4 Fingerprinting for AI Scraper Detection&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/honeypot-traps-forms-buttons-endpoints-practical-guide/" rel="noopener noreferrer"&gt;Honeypot Traps: Forms, Buttons &amp;amp; Endpoints&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/detecting-vision-based-ai-agents-operator-computer-use/" rel="noopener noreferrer"&gt;Detecting Vision-Based AI Agents&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>Next.js Bot Detection: Block AI Crawlers at the Edge</title>
      <dc:creator>webdecoy</dc:creator>
      <pubDate>Sun, 13 Sep 2026 14:14:57 +0000</pubDate>
      <link>https://dev.to/webdecoy/nextjs-bot-detection-block-ai-crawlers-at-the-edge-4dd4</link>
      <guid>https://dev.to/webdecoy/nextjs-bot-detection-block-ai-crawlers-at-the-edge-4dd4</guid>
      <description>&lt;p&gt;Your Vercel usage graph is climbing and your logs are full of names you did not invite: GPTBot, ClaudeBot, PerplexityBot, Bytespider. They hammer your App Router pages and quietly run up your bandwidth and compute bill.&lt;/p&gt;

&lt;p&gt;The reflex is a ten-line user-agent block in &lt;code&gt;middleware.ts&lt;/code&gt;. After you ship it the graph looks calmer for a day. Then it climbs again.&lt;/p&gt;

&lt;p&gt;The ten-line block is not wrong. It is just the first of three layers, and on its own it catches only the crawlers honest enough to tell you who they are. This is a working guide to all three in a normal Next.js project: an edge gate on every request, honeypot routes that catch the bots that lie, and an origin fingerprint check for the signal the edge genuinely cannot see.&lt;/p&gt;

&lt;p&gt;We will also be honest about that last part, because most tutorials are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive block, and exactly why it fails
&lt;/h2&gt;

&lt;p&gt;Almost every Next.js bot-blocking guide ends here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// middleware.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BLOCKED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/GPTBot|ClaudeBot|PerplexityBot|Bytespider|CCBot|Google-Extended|Meta-ExternalAgent|Amazonbot/i&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ua&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user-agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;BLOCKED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Forbidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/((?!_next/static|_next/image|favicon.ico).*)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works against a crawler that announces itself. GPTBot sends a user agent that says GPTBot, you match it, you return 403. Done.&lt;/p&gt;

&lt;p&gt;The problem is that &lt;strong&gt;a user agent is a string the client chooses.&lt;/strong&gt; Nothing forces a scraper to keep telling the truth, and the moment blocking becomes common, the well-funded scrapers stop. Perplexity was reported through 2025 to fetch pages with a generic Chrome user agent and rotating addresses after its declared bot was blocked. A scraper running headless Chrome or a plain HTTP client can set any user-agent header it likes in one line. Your regex never sees them.&lt;/p&gt;

&lt;p&gt;So the honest framing: &lt;strong&gt;a user-agent block is a politeness filter.&lt;/strong&gt; It removes the crawlers that respect your wishes, which is real and worth doing, and it does nothing to the ones that do not. The same logic applies to robots.txt, which is a request rather than a rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer one: a real edge middleware
&lt;/h2&gt;

&lt;p&gt;Keep the user-agent gate, but stop treating it as the whole defense. A useful middleware does three jobs: cheaply block the honest crawlers, rate limit everyone else so a single client cannot flood you, and hand a signal to your origin so the deeper check knows where to look.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where middleware lives, and what &lt;code&gt;matcher&lt;/code&gt; does
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;middleware.ts&lt;/code&gt; sits at the root of your project, or inside &lt;code&gt;src/&lt;/code&gt;. It runs on the Edge Runtime by default, before your routes and before cached output — exactly why it is the right place for a first gate. The request is stopped before it costs you a function invocation or a database hit.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;matcher&lt;/code&gt; is your most important performance setting. Without it, middleware runs on every asset, including static files Next.js already serves for free:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="c1"&gt;// run on everything except Next internals and static files&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Block, rate limit, or rewrite
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;NextResponse&lt;/code&gt; gives you three moves inside middleware:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Forbidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;          &lt;span class="c1"&gt;// block&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Too Many Requests&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;  &lt;span class="c1"&gt;// rate limit&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rewrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/tarpit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;       &lt;span class="c1"&gt;// send to a decoy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can hand-roll the gate from here, but it adds up fast: a regex of declared crawlers to maintain, plus a shared rate-limit store — an in-process counter (a plain &lt;code&gt;Map&lt;/code&gt;) will not hold when edge invocations do not share memory, so you reach for Upstash Redis or Vercel KV.&lt;/p&gt;

&lt;p&gt;That is ongoing work, and it is the work &lt;code&gt;@webdecoy/nextjs&lt;/code&gt; exists to remove:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @webdecoy/nextjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// middleware.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;withWebDecoy&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@webdecoy/nextjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rateLimit&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@webdecoy/node&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;withWebDecoy&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WEBDECOY_API_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Built-in rules engine: no separate counter store to stand up.&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;rateLimit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;window&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;})],&lt;/span&gt; &lt;span class="c1"&gt;// 100 requests per 60s&lt;/span&gt;
  &lt;span class="c1"&gt;// Skip work on paths that never need protection.&lt;/span&gt;
  &lt;span class="na"&gt;skipPaths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/_next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/favicon.ico&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/robots.txt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/((?!_next/static|_next/image|favicon.ico).*)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On every matched request this runs local analysis, applies your rules, and returns the right response on its own: a tripped &lt;code&gt;rateLimit&lt;/code&gt; returns &lt;code&gt;429&lt;/code&gt; with a &lt;code&gt;Retry-After&lt;/code&gt; header, a deny rule returns &lt;code&gt;403&lt;/code&gt;, and an allowed request continues with an &lt;code&gt;x-webdecoy-decision&lt;/code&gt; header so your routes can read the verdict downstream. &lt;code&gt;onBlocked&lt;/code&gt; and &lt;code&gt;onError&lt;/code&gt; let you override, and &lt;code&gt;onError&lt;/code&gt; fails open by default, so a hiccup in detection never locks out real users.&lt;/p&gt;

&lt;p&gt;This is a real improvement over the ten-line version. But notice what every signal so far has in common. User agent, headers, address, and request rate are &lt;strong&gt;all things the client controls or can rotate.&lt;/strong&gt; To catch a bot that lies about all of them, you need a signal it does not get to set: its own TLS handshake.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest constraint nobody mentions
&lt;/h2&gt;

&lt;p&gt;Here is the part most Next.js guides skip, and it holds whether you hand-roll the gate or use a package: &lt;strong&gt;you cannot compute a TLS fingerprint inside &lt;code&gt;middleware.ts&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A JA3 or JA4 fingerprint is built from the raw ClientHello of the TLS handshake — the cipher suites, the extensions and their order, the supported groups, the way the client negotiates the connection. These are extremely hard to fake because they come from the client's TLS stack rather than from a header.&lt;/p&gt;

&lt;p&gt;The catch on a platform like Vercel is that &lt;strong&gt;TLS terminates at the edge network before your middleware runs.&lt;/strong&gt; By the time your code executes, the handshake is over and the ClientHello bytes are gone. The Edge Runtime has no socket access and no &lt;code&gt;node:tls&lt;/code&gt;, so there is nothing to read. Recent Next.js versions let you move middleware to the Node.js runtime, which is useful for other reasons, but it still does not hand you the original handshake.&lt;/p&gt;

&lt;p&gt;This is not a flaw in Next.js. It is just where the layers sit. Put the fingerprint check where the handshake is visible:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your own origin&lt;/strong&gt;, when you self-host Next.js behind your own TLS termination (&lt;code&gt;next start&lt;/code&gt; behind nginx or Caddy), where the proxy reads the handshake and forwards it as headers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A detection service&lt;/strong&gt; that captures those handshake signals for you and returns a verdict your route handler can act on.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the architecture becomes: gate cheaply at the edge, trap the liars with honeypots, run the fingerprint check at the origin where the signal lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer two: honeypot routes in the App Router
&lt;/h2&gt;

&lt;p&gt;A honeypot exploits a simple asymmetry: &lt;strong&gt;a real visitor never touches it, so any hit is suspicious by definition.&lt;/strong&gt; The classic version is a hidden form field. For a Next.js crawler problem, a honeypot &lt;em&gt;route&lt;/em&gt; is a better fit, because crawlers follow links and probe paths humans never click.&lt;/p&gt;

&lt;p&gt;First, plant a decoy link that humans cannot see but a link-following scraper will. Put it in your layout, and disallow the path in robots.txt so honest crawlers skip it — anything that fetches it has both ignored robots.txt and followed an invisible link, which is a strong signal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/layout.tsx (excerpt)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RootLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Invisible to humans, irresistible to link-scraping bots. */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/api/trap"&lt;/span&gt; &lt;span class="na"&gt;aria-hidden&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="na"&gt;tabIndex&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
           &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;absolute&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-9999px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          Account archive
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the trap itself — a route handler that records the hit and responds blandly so the bot does not learn it was caught:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/trap/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;flagClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/threat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-forwarded-for&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ua&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user-agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;

  &lt;span class="c1"&gt;// Record the hit. Anything reaching this route is presumed automated.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;flagClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ua&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;honeypot:trap&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="c1"&gt;// Respond like a boring empty resource. Do not reveal the trap.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;204&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now your middleware reads that stored flag and acts on it before serving real content. Rewrite flagged traffic to a tarpit instead of your actual route, which keeps the URL stable so the bot does not notice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// inside middleware(), after the rate-limit check&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isFlagged&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/threat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;isFlagged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rewrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/tarpit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same pattern extends to fake API endpoints. A path like &lt;code&gt;/api/v1/users/export&lt;/code&gt; that your real app never calls, but a scraper probing for data will, becomes a high-confidence trap.&lt;/p&gt;

&lt;p&gt;Honeypots are powerful because they need no fingerprint and no machine learning. They exploit the gap between how a human and a script move through a site. But a careful scraper that only fetches linked, allowed pages at a human pace will avoid them. That is the gap layer three closes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer three: origin fingerprinting
&lt;/h2&gt;

&lt;p&gt;For the bot that spoofs its user agent, rotates its address, paces itself, and avoids your traps, you need the one thing it cannot rewrite: its TLS handshake. As covered above, that check has to run at the origin, in a Node runtime, not in edge middleware.&lt;/p&gt;

&lt;p&gt;Next.js route handlers default to the Node.js runtime, which makes them the right home. The core SDK runs a two-tier check: a fast local pass on your server (suspicious headers, datacenter IP ranges, known bot user agents, missing client hints), and a deeper pass using JA3/JA4 fingerprinting to flag the case where a request &lt;strong&gt;claims to be Chrome but handshakes like curl.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @webdecoy/node
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/checkout/route.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nodejs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// the Edge Runtime cannot see the handshake&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;WebDecoy&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@webdecoy/node&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;webdecoy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebDecoy&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WEBDECOY_API_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;enableTLSFingerprinting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;threatScoreThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// block at a threat score of 70 or higher&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;webdecoy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;protect&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-forwarded-for&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0.0.0.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;user_agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user-agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// result.detection carries decision, confidence (0 to 100), and bot_type.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Request blocked&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;One honest caveat about where each tier can run.&lt;/strong&gt; The local pass works anywhere, including a route handler on Vercel, because it only reads headers and the address. The JA3/JA4 pass needs the client's actual handshake, and your function only sees that when it has socket access. Self-hosting behind nginx or Caddy, you forward the handshake details as headers and the SDK gets the full fingerprint. On Vercel's managed edge, you lean on the local signals and put the deep fingerprint check on a self-hosted origin or proxy.&lt;/p&gt;

&lt;p&gt;Still on the Pages Router? The same package gives you a handler wrapper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// pages/api/checkout.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;withBotProtection&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@webdecoy/nextjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextApiRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;NextApiResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextApiRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextApiResponse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="c1"&gt;// req.webdecoy holds the detection result&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;withBotProtection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WEBDECOY_API_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;blockThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  One decision from three signals
&lt;/h3&gt;

&lt;p&gt;The point of three layers is that they cover each other's blind spots. Combine them into a single verdict rather than three disconnected checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/lib/decide.ts&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Signals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;edgeScreened&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;   &lt;span class="c1"&gt;// passed the edge user-agent and rate gate&lt;/span&gt;
  &lt;span class="na"&gt;honeypotHit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;    &lt;span class="c1"&gt;// touched a trap at any point&lt;/span&gt;
  &lt;span class="na"&gt;threatScore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;     &lt;span class="c1"&gt;// 0 to 100, from result.detection.confidence&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Signals&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;challenge&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;block&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;honeypotHit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;block&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;           &lt;span class="c1"&gt;// touched a trap: automated by definition&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;threatScore&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;block&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;     &lt;span class="c1"&gt;// handshake or local signals say automation&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;threatScore&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;challenge&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// suspicious, verify before trusting&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A naive HTTP scraper trips the edge gate. A link-following scraper that lies about its user agent trips a honeypot. A polished headless browser that avoids the traps trips the fingerprint. To get past all three, a bot has to be honest, careful, and use a real browser TLS stack &lt;strong&gt;at the same time&lt;/strong&gt; — a much smaller and more expensive population than the flood you started with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vercel BotID versus a self-hosted stack
&lt;/h2&gt;

&lt;p&gt;If you are on Vercel you have probably seen BotID, the invisible bot-detection product powered by Kasada. It is genuinely good, and worth knowing where it fits.&lt;/p&gt;

&lt;p&gt;BotID is a &lt;strong&gt;managed black box.&lt;/strong&gt; You enable it on the routes you want protected and it makes a verdict at the edge, with no signals to inspect and no logic to tune. That is the appeal and the limitation: strong detection with almost no code, in exchange for visibility into why a request was flagged, portability off Vercel, and the ability to combine the verdict with your own honeypots and scoring. It is also a paid feature once you scale.&lt;/p&gt;

&lt;p&gt;The self-hosted stack in this guide is the opposite trade: more code and more moving parts, in return for portability to any host, transparency about every signal, and thresholds that are yours to tune. They are not mutually exclusive — some teams run BotID on checkout and login for the managed guarantee, and run the edge gate plus honeypots plus origin fingerprinting everywhere else for coverage and insight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scope the matcher.&lt;/strong&gt; Never run middleware on &lt;code&gt;_next/static&lt;/code&gt;, images, or other assets. Wasted compute, and it can break caching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not hard-block on user agent alone.&lt;/strong&gt; Treat it as the cheap first pass, then escalate. A single spoofed header should not be enough to ban a visitor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allow the good bots on purpose.&lt;/strong&gt; Verify Googlebot and Bingbot by reverse DNS rather than trusting the user-agent string, and decide deliberately which AI crawlers you keep. Some AI search engines send referral traffic worth having.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch your false positive rate.&lt;/strong&gt; Log every block and challenge with the reason, and review the challenge bucket. If real users land there, loosen the thresholds in &lt;code&gt;decide()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail open, not closed.&lt;/strong&gt; If the fingerprint service is briefly unreachable, decide whether a timeout should allow or challenge. For most sites, allowing on timeout beats locking out real customers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure the bill.&lt;/strong&gt; The whole point was bandwidth and compute. Watch the usage graph for a week after launch so you can prove the layers are paying for themselves.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The shape that works in Next.js is layered: a cheap edge gate that screens and rate limits, honeypot routes that catch the bots that lie, and an origin fingerprint check for the signal the edge cannot see. None of it requires a separate WAF or DNS surgery, and the one real constraint — that TLS fingerprinting cannot happen in edge middleware — is a reason to move that check to the origin, not a reason to skip it.&lt;/p&gt;

&lt;p&gt;What is your site seeing from AI crawlers lately? Curious whether others are blocking outright or rate limiting and keeping the referral traffic.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://webdecoy.com/blog/nextjs-bot-detection-edge-middleware-block-ai-crawlers/" rel="noopener noreferrer"&gt;webdecoy.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related reading:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/detect-ai-scrapers-gptbot-claudebot-perplexity/" rel="noopener noreferrer"&gt;Detect AI Scrapers: Block GPTBot, ClaudeBot &amp;amp; More&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/ja4-fingerprinting-ai-scrapers-practical-guide/" rel="noopener noreferrer"&gt;JA4 Fingerprinting for AI Scraper Detection&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/honeypot-traps-forms-buttons-endpoints-practical-guide/" rel="noopener noreferrer"&gt;Honeypot Traps: Forms, Buttons &amp;amp; Endpoints&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>security</category>
      <category>ai</category>
    </item>
    <item>
      <title>How to Detect Browser-as-a-Service Scrapers in 2025</title>
      <dc:creator>webdecoy</dc:creator>
      <pubDate>Tue, 16 Dec 2025 19:41:08 +0000</pubDate>
      <link>https://dev.to/webdecoy/how-to-detect-browser-as-a-service-scrapers-in-2025-mmk</link>
      <guid>https://dev.to/webdecoy/how-to-detect-browser-as-a-service-scrapers-in-2025-mmk</guid>
      <description>&lt;p&gt;Browserbase just raised $40 million at a $300 million valuation. Their pitch to developers? Run thousands of headless browsers in the cloud with "stealth mechanisms to avoid bot detection." They're not alone.&lt;/p&gt;

&lt;p&gt;A new category of infrastructure has emerged: &lt;strong&gt;Browser-as-a-Service (BaaS)&lt;/strong&gt;. These platforms provide cloud-hosted Chromium instances specifically designed to evade detection. They rotate residential IPs, spoof user agents, strip automation markers, and patch JavaScript APIs. Their entire value proposition is making your bot detection obsolete.&lt;/p&gt;

&lt;p&gt;The market is exploding. Browserbase has 20,000+ developer signups running 50 million browser sessions. Skyvern automates browser workflows with computer vision and LLMs. Hyperbrowser markets itself as "purpose-built for AI agents that operate on websites with advanced detection systems."&lt;/p&gt;

&lt;p&gt;Here's the uncomfortable truth: &lt;strong&gt;traditional bot detection cannot catch them&lt;/strong&gt;. But behavioral analysis can.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Rise of Browser-as-a-Service
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What BaaS Platforms Actually Do
&lt;/h3&gt;

&lt;p&gt;Browser-as-a-Service platforms provide cloud-hosted browser infrastructure for automation at scale. Unlike traditional scraping tools that send raw HTTP requests, BaaS platforms run real Chromium browsers that execute JavaScript, render pages, and maintain sessions exactly like legitimate users.&lt;/p&gt;

&lt;p&gt;The major players in 2025:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browserbase&lt;/strong&gt; - The market leader with $67.5 million in total funding. Offers managed headless browsers with session persistence, proxy support, and their Stagehand SDK for AI agent development. Used by Perplexity, Vercel, and 11x.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skyvern&lt;/strong&gt; - Y Combinator-backed platform that combines computer vision with LLMs to automate browser workflows. Claims 64.4% accuracy on WebBench benchmarks. Specializes in form filling, login automation, and RPA tasks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hyperbrowser&lt;/strong&gt; - Explicitly "purpose-built for AI agents that operate on websites with advanced detection systems." Focuses on stealth, persistence, and staying undetected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser Use&lt;/strong&gt; - Open-source alternative gaining traction. Provides browser automation primitives that integrate with various AI frameworks.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Business Model: Stealth as a Feature
&lt;/h3&gt;

&lt;p&gt;These platforms compete on evasion capability. From Browserbase's marketing: "stealth mechanisms to avoid bot detection." From Hyperbrowser: "engineered to stay undetected and maintain stable sessions over time, even on sites with aggressive anti-bot measures."&lt;/p&gt;

&lt;p&gt;This is not subtle. Stealth is the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  How BaaS Platforms Evade Traditional Detection
&lt;/h2&gt;

&lt;p&gt;Understanding evasion techniques is essential for building detection that works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stripping navigator.webdriver
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;navigator.webdriver&lt;/code&gt; property is set to &lt;code&gt;true&lt;/code&gt; when a browser is controlled by automation tools. Every BaaS platform removes it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// What detection checks for&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webdriver&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;flagAsBot&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// How BaaS platforms evade&lt;/span&gt;
&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defineProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webdriver&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Dynamic User-Agent Generation
&lt;/h3&gt;

&lt;p&gt;BaaS platforms generate different user agents for each session. Stytch's research revealed: &lt;strong&gt;Browserbase generates slightly different user-agents each session, which sometimes aligns with the underlying Chromium runtime but sometimes attempts to be deceptive.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This creates detectable inconsistencies. The user agent claims Chrome 120, but the TLS fingerprint reveals the true Chromium version.&lt;/p&gt;

&lt;h3&gt;
  
  
  Patching JavaScript APIs
&lt;/h3&gt;

&lt;p&gt;Modern stealth frameworks patch dozens of browser APIs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Chrome object spoofing&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chrome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
  &lt;span class="na"&gt;loadTimes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
  &lt;span class="na"&gt;csi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Plugins array spoofing&lt;/span&gt;
&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defineProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;plugins&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Chrome PDF Plugin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;internal-pdf-viewer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Chrome PDF Viewer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mhjfbmdgcfjbbpaeojofohoefgiehjai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Native Client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;internal-nacl-plugin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Puppeteer Stealth includes 17 separate evasion modules. BaaS platforms build on these with proprietary improvements.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Stealth Mode Fails Against Behavioral Analysis
&lt;/h2&gt;

&lt;p&gt;BaaS platforms have solved the static fingerprinting problem. What they cannot solve: &lt;strong&gt;making automation behave like humans&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mouse Movement Entropy
&lt;/h3&gt;

&lt;p&gt;Human mouse movement is chaotic. We overshoot targets, correct course, accelerate irregularly, and move in curves. Automation moves efficiently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Human mouse movement characteristics&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;movement_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;147&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;linear_path_ratio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// Mostly curved paths&lt;/span&gt;
  &lt;span class="nx"&gt;velocity_variance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.84&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// Highly variable speed&lt;/span&gt;
  &lt;span class="nx"&gt;overshoots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// BaaS automation characteristics&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;movement_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;linear_path_ratio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.91&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// Straight lines&lt;/span&gt;
  &lt;span class="na"&gt;velocity_variance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// Constant speed&lt;/span&gt;
  &lt;span class="na"&gt;overshoots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even with "human-like" randomization, statistical analysis reveals synthetic patterns.&lt;/p&gt;

&lt;h3&gt;
  
  
  Click Timing Distributions
&lt;/h3&gt;

&lt;p&gt;Human reaction times follow specific distributions—200-400ms for simple targets with characteristic right-skewed distribution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Human click timing (ms from target appearing)&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;247&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;312&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;289&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;198&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;267&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;334&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;223&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;278&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="c1"&gt;// Mean: 271ms, Std Dev: 42ms&lt;/span&gt;

&lt;span class="c1"&gt;// BaaS automation click timing&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;160&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;170&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;155&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;175&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;165&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;145&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;185&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;158&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="c1"&gt;// Mean: 164ms, Std Dev: 13ms — too consistent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Honeypot Link Effectiveness
&lt;/h3&gt;

&lt;p&gt;The most reliable detection: invisible traps that only automation follows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Hidden from visual users, visible in DOM --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/admin-backup-2024"&lt;/span&gt;
   &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"position:absolute;left:-9999px;opacity:0;pointer-events:none;"&lt;/span&gt;
   &lt;span class="na"&gt;tabindex=&lt;/span&gt;&lt;span class="s"&gt;"-1"&lt;/span&gt;
   &lt;span class="na"&gt;aria-hidden=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  Admin Backup Portal
&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Human users never see this link. Bots parsing HTML will find it. Any interaction is definitive proof of automation.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;We built &lt;a href="https://webdecoy.com" rel="noopener noreferrer"&gt;WebDecoy&lt;/a&gt; around this approach. Honeypots plus behavioral analysis plus TLS fingerprinting.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Detection Techniques That Actually Work
&lt;/h2&gt;

&lt;h3&gt;
  
  
  TLS/JA3/JA4 Fingerprinting
&lt;/h3&gt;

&lt;p&gt;Every TLS handshake reveals the true client identity. The cipher suites, their order, extensions, and protocol versions create a unique fingerprint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Real Chrome 120 JA4:
t13d1517h2_8daaf6152771_b0da82dd1658

Browserbase session claiming Chrome 120:
t13d1516h2_8daaf6152771_a9f2e3c71b42
// Different hash reveals different TLS stack
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even when the user agent claims Chrome 120, the TLS fingerprint reveals the actual Chromium version. The mismatch is a strong bot signal. (&lt;a href="https://webdecoy.com/blog/webdecoy-sdk-release-tls-fingerprinting/" rel="noopener noreferrer"&gt;Deep dive on TLS fingerprinting&lt;/a&gt;)&lt;/p&gt;

&lt;h3&gt;
  
  
  Browser Capability Verification
&lt;/h3&gt;

&lt;p&gt;The claimed browser should support specific capabilities:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// If User-Agent claims Chrome 120&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expectedFeatures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Array.prototype.toSorted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// Added Chrome 110&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Array.prototype.toReversed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// Added Chrome 110&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;structuredClone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;// Added Chrome 98&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expectedFeatures&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;eval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`typeof &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; !== 'undefined'`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;flagAsInconsistent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;capability_mismatch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  JavaScript Environment Consistency
&lt;/h3&gt;

&lt;p&gt;Stealth patches leave traces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Check if navigator.webdriver was patched&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;descriptor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOwnPropertyDescriptor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webdriver&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;descriptor&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;descriptor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;descriptor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;get&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;flagAsStealth&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Check for override detection&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nativeCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\[&lt;/span&gt;&lt;span class="sr"&gt;native code&lt;/span&gt;&lt;span class="se"&gt;\]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;nativeCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;flagAsStealth&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Canvas/WebGL Fingerprint Anomalies
&lt;/h3&gt;

&lt;p&gt;BaaS platforms run on cloud infrastructure without GPUs. They use software rendering that produces distinct fingerprints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;detectSoftwareRendering&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webgl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;debugInfo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getExtension&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WEBGL_debug_renderer_info&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;renderer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getParameter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;debugInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UNMASKED_RENDERER_WEBGL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;softwareIndicators&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SwiftShader&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;llvmpipe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Mesa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Software Rasterizer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ANGLE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;softwareIndicators&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;renderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real users have real GPUs. Cloud browsers have software rendering.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-Signal Correlation
&lt;/h3&gt;

&lt;p&gt;No single signal is definitive. Combine weak signals into strong verdicts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BotDetector&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;weights&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;tls_mismatch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;software_renderer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;stealth_patches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;behavioral_anomaly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;honeypot_interaction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;mouse_entropy_low&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;calculateScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;detected&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;detected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;getVerdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;block&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;challenge&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flag&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you don't want to build this yourself, &lt;a href="https://webdecoy.com/product/sdks/" rel="noopener noreferrer"&gt;WebDecoy's SDK&lt;/a&gt; handles the scoring, SIEM integration, and response automation out of the box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation Recommendations
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Start with Honeypots
&lt;/h3&gt;

&lt;p&gt;Honeypots provide the highest confidence signals with zero false positives. Deploy immediately:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Hidden form fields that trigger on any input&lt;/li&gt;
&lt;li&gt;Invisible links to trap endpoints&lt;/li&gt;
&lt;li&gt;CSS-hidden content that only parsers see&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Layer Detection Methods
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Honeypots&lt;/strong&gt; (zero false positives, catches 70-80%)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLS fingerprinting&lt;/strong&gt; (fast, server-side)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Behavioral analysis&lt;/strong&gt; (catches sophisticated evasion)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-signal correlation&lt;/strong&gt; (highest accuracy)&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Use Progressive Challenges
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Low confidence&lt;/strong&gt;: Log and observe&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Medium confidence&lt;/strong&gt;: Rate limit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High confidence&lt;/strong&gt;: CAPTCHA challenge&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Definitive (honeypot)&lt;/strong&gt;: Block&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Arms Race Continues
&lt;/h2&gt;

&lt;p&gt;Browser-as-a-Service is not going away. The market is growing, funding is flowing, and the platforms are getting more sophisticated.&lt;/p&gt;

&lt;p&gt;But the fundamental asymmetry favors defenders who invest in behavioral analysis. BaaS platforms can fake technical fingerprints. They cannot fake being human.&lt;/p&gt;

&lt;p&gt;The question is not whether you can detect BaaS scrapers. The question is whether your current solution is designed for this threat.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://webdecoy.com/blog/browser-as-a-service-detection-baas-ai-agents-2025/" rel="noopener noreferrer"&gt;webdecoy.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Want to catch BaaS scrapers without building it yourself?&lt;/strong&gt; &lt;a href="https://app.webdecoy.com/" rel="noopener noreferrer"&gt;Try WebDecoy&lt;/a&gt; — deploys in 5 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More on this topic:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/bot-scanner-pro-ai-browser-detection-stagehand-browserbase-playwright/" rel="noopener noreferrer"&gt;Bot Scanner Pro: Catching Stagehand and Browserbase&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/webdecoy-sdk-release-tls-fingerprinting/" rel="noopener noreferrer"&gt;TLS Fingerprinting with WebDecoy SDK&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://webdecoy.com/blog/headless-browser-detection-playwright-puppeteer-selenium/" rel="noopener noreferrer"&gt;Headless Browser Detection: Playwright, Puppeteer, Selenium&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What's your experience with BaaS scrapers? Drop a comment below.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
