<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[WebDecoy Blog]]></title><description><![CDATA[WebDecoy Blog]]></description><link>https://webdecoy.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>WebDecoy Blog</title><link>https://webdecoy.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 17 Sep 2026 08:17:24 GMT</lastBuildDate><atom:link href="https://webdecoy.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Inside a WordPress Bot Detection Engine]]></title><description><![CDATA[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-]]></description><link>https://webdecoy.hashnode.dev/inside-a-wordpress-bot-detection-engine</link><guid isPermaLink="true">https://webdecoy.hashnode.dev/inside-a-wordpress-bot-detection-engine</guid><category><![CDATA[WordPress]]></category><category><![CDATA[PHP]]></category><category><![CDATA[Security]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Chris Portscheller]]></dc:creator><pubDate>Sun, 13 Sep 2026 18:38:56 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aa6b18e99a13646db1b5cfc/277d8247-b8d5-4cbc-9f31-46c3c2709a18.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>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.</p>
<p>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.</p>
<h2>The Detection Pipeline</h2>
<p>Every request flows through a pipeline that evaluates it before WordPress processes it:</p>
<pre><code class="language-plaintext">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;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
</code></pre>
<p>The first two checks are fast exits. Blocked IPs get rejected immediately. Verified good bots skip detection entirely. Everything else gets scored.</p>
<h2>Threat Scoring: 0 to 100</h2>
<p>Every detection signal adds points to a threat score. The score determines what happens to the request:</p>
<table>
<thead>
<tr>
<th>Score Range</th>
<th>Severity</th>
<th>Action</th>
</tr>
</thead>
<tbody><tr>
<td>0–19</td>
<td>Minimal</td>
<td>Allow (likely human)</td>
</tr>
<tr>
<td>20–39</td>
<td>Low</td>
<td>Log only</td>
</tr>
<tr>
<td>40–59</td>
<td>Medium</td>
<td>Optional challenge</td>
</tr>
<tr>
<td>60–74</td>
<td>High</td>
<td>Challenge or block</td>
</tr>
<tr>
<td>75–100</td>
<td>Critical</td>
<td>Automatic block</td>
</tr>
</tbody></table>
<p>The default blocking threshold is 75, configurable in settings. Scores at 40 and above are logged for review.</p>
<p><strong>The scoring is additive.</strong> 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.</p>
<p>Base scores for common signals:</p>
<pre><code class="language-plaintext">Missing standard headers:      10-30
No cookies on non-first visit:    15
Suspicious user agent:            25
Known bot user agent:             50
curl / wget / python-requests:    35
Automation tool detected:         40
Headless browser markers:         25
Rate limit exceeded:              25
Honeypot field triggered:         60
Fake bot (failed DNS verify):     80
</code></pre>
<p>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.</p>
<h2>Server-Side Analysis</h2>
<h3>User-Agent and header consistency</h3>
<p>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.</p>
<p><strong>Missing</strong> <code>Accept-Language</code> <strong>is a strong signal.</strong> Every real browser sends it. Most HTTP libraries don't unless explicitly configured.</p>
<h3>MITRE ATT&amp;CK path matching</h3>
<p>This is one of the more distinctive pieces. Rather than maintaining an arbitrary blocklist of "bad" URLs, detection is organized by attacker <em>tactic</em>:</p>
<pre><code class="language-plaintext">Credential Access (TA0006):
  .env, wp-config.php, .git/, *.sql         → +30 points

Collection (TA0009):
  Backup files, database dumps              → +25 points

Reconnaissance (TA0043):
  Admin probes, user enumeration            → +20 points

Discovery (TA0007):
  Debug endpoints, phpinfo, server-status   → +20 points
</code></pre>
<p>When an IP requests <code>/wp-config.php.bak</code>, then <code>/.env</code>, then <code>/.git/config</code>, each request scores individually while the rate limiter tracks velocity. The combined effect is rapid escalation to the blocking threshold.</p>
<p>The mapping isn't only for scoring. It surfaces in the detections table, so you can see a blocked IP was performing <em>credential access reconnaissance</em> rather than just "requesting bad URLs." The categorization tells you what attackers are actually looking for.</p>
<h3>Rate limiting</h3>
<p>Tracks requests per IP with a configurable window (default: 60 requests per 60 seconds). Exceeding it adds 25 points and can trigger automatic blocking.</p>
<p>The limiter uses the WordPress database for tracking, so it works behind load balancers and CDNs <strong>as long as the real client IP is forwarded</strong> in a standard header (<code>X-Forwarded-For</code>, <code>X-Real-IP</code>, or <code>CF-Connecting-IP</code>).</p>
<h2>Client-Side Detection</h2>
<p>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.</p>
<p>A scanner script loads with <code>defer</code> so it never blocks rendering, runs environment checks, and submits results alongside form data.</p>
<p><strong>WebDriver detection</strong> — the simplest check. Selenium, Puppeteer, and Playwright all set <code>navigator.webdriver = true</code> by default. Stealth plugins override this, but it still catches unmodified tooling.</p>
<p><strong>Headless markers</strong> — <code>HeadlessChrome</code> in the UA string, missing <code>chrome.runtime</code> and <code>chrome.app</code> objects (present in real Chrome, absent in headless), PhantomJS signatures on <code>window</code>.</p>
<p><strong>Chrome consistency</strong> — a request claiming Chrome should have the <code>chrome</code> global with <code>chrome.runtime</code>, <code>chrome.app</code>, <code>chrome.csi</code>. If the UA says Chrome but these are missing or structurally wrong, the environment has been tampered with.</p>
<h3>Behavioral scoring</h3>
<p>For form submissions, the plugin evaluates <em>how</em> the user interacted with the page. This is where most sophisticated bots fail, because generating convincing human behavior at scale is genuinely hard.</p>
<pre><code class="language-plaintext">Behavioral signals (40% weight):
  - Mouse velocity variance
  - Straight-line movement ratio
  - Micro-tremor score (natural hand movement)

Environmental signals (35% weight):
  - Headless browser markers
  - Automation framework detection
  - Browser API consistency

Temporal signals (15% weight):
  - Time on page before submission
  - Form completion velocity
  - Session duration

Form signals (10% weight):
  - Honeypot field triggers
  - Field completion order
  - Paste detection
</code></pre>
<p><strong>Mouse velocity variance</strong> 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.</p>
<p><strong>Straight-line movement ratio</strong> measures what percentage of movements travel in perfectly straight lines. Humans almost never do, because of micro-tremors and natural imprecision.</p>
<p><strong>Micro-tremor score</strong> 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.</p>
<h3>Honeypot fields, rotated daily</h3>
<p>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.</p>
<p>What makes the implementation interesting is the obfuscation. Instead of obvious names like <code>honeypot</code> or <code>trap</code>, the plugin generates legitimate-looking field names that <strong>change daily</strong>:</p>
<pre><code class="language-php">// Field names rotate using a daily seed
// Examples of generated names:
//   contact_name, user_email, address_field, phone_number
// CSS class prefixes mimic common form frameworks:
//   form-, input-, wp-, cf-, gform-, ninja-
</code></pre>
<p>Daily rotation stops bot operators hardcoding a skip-list of honeypot names. The realistic naming defeats bots that filter for obvious trap patterns.</p>
<h2>Proof-of-Work Challenges</h2>
<p>The layer that makes automated attacks economically painful even when bots pass everything else.</p>
<p>When a form loads, the server generates a challenge: a random hex prefix and a difficulty parameter. The client must find a nonce where <code>SHA-256(prefix + nonce)</code> starts with N zero hex characters. There's no shortcut — it's brute force.</p>
<pre><code class="language-plaintext">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 }
</code></pre>
<p>At difficulty 4, the client tries roughly <strong>65,536 hashes</strong> on average — milliseconds on modern hardware, entirely in the background while the user fills out the form. They never see it.</p>
<p><strong>Why it stops bots:</strong> 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.</p>
<p>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.</p>
<h3>Replay prevention</h3>
<p>Each challenge carries an HMAC signature generated with WordPress's <code>AUTH_KEY</code> salt. The server verifies the signature before checking the hash, which prevents:</p>
<ul>
<li><p><strong>Challenge reuse</strong> — each challenge ID is single-use</p>
</li>
<li><p><strong>Challenge tampering</strong> — difficulty and prefix are signed, so they can't be modified</p>
</li>
<li><p><strong>Challenge forging</strong> — without <code>AUTH_KEY</code>, valid signatures can't be generated</p>
</li>
<li><p><strong>Stockpiling</strong> — a 5-minute TTL kills pre-solved challenges</p>
</li>
</ul>
<p>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.</p>
<h2>Good Bot Verification</h2>
<p>Not all bots are bad. Googlebot, Bingbot, and 60+ other legitimate crawlers need unimpeded access for indexing, previews, uptime monitoring, and SEO tooling.</p>
<pre><code class="language-plaintext">Search engines:   Googlebot, Bingbot, YandexBot, Baiduspider,
                  DuckDuckBot, Applebot
Social:           Facebook, LinkedIn, Twitter, Pinterest
Monitoring:       Pingdom, UptimeRobot, StatusCake, Datadog
SEO tools:        Ahrefs, SEMrush, Moz, Majestic
Feed readers:     Feedly, NewsBlur
AI crawlers:      GPTBot, ClaudeBot, PerplexityBot (optional blocking)
</code></pre>
<p>For verifiable bots, the plugin does forward-confirmed reverse DNS:</p>
<ol>
<li><p>Look up the requesting IP's hostname via reverse DNS</p>
</li>
<li><p>Check the hostname ends with a verified domain (<code>.googlebot.com</code>, <code>.google.com</code>)</p>
</li>
<li><p>Forward-resolve that hostname back to an IP</p>
</li>
<li><p>Confirm it matches the original requesting IP</p>
</li>
</ol>
<p><strong>A fake Googlebot scores +80 — an instant block</strong> — 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.</p>
<p>This matters more than it sounds: matching <code>Googlebot</code> in a User-Agent string and allowing it is a bypass, not an allowlist. Anyone can send that string.</p>
<h2>WooCommerce: carding defense</h2>
<p>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.</p>
<p><strong>Checkout velocity limiting</strong> — 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.</p>
<p><strong>Card testing pattern detection</strong> — 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.</p>
<p>Compatible with classic checkout and WooCommerce Blocks, and declares HPOS (High-Performance Order Storage) compatibility.</p>
<h2>Architecture decisions worth calling out</h2>
<p><strong>No external dependencies for core protection.</strong> 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.</p>
<p><strong>Additive scoring over binary decisions.</strong> 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.</p>
<p><strong>Daily rotating honeypot names.</strong> 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.</p>
<p><strong>HMAC-signed challenges instead of stored ones.</strong> The signature itself proves the challenge is legitimate and unmodified — no database writes, no storage-exhaustion vector.</p>
<h2>Getting started</h2>
<pre><code class="language-bash">wp plugin install webdecoy --activate
</code></pre>
<p>Or: <strong>Plugins → Add New → search "WebDecoy" → Install → Activate</strong>.</p>
<p>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.</p>
<hr />
<p>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.</p>
<p><em>How do you handle bot scoring — hard rules or weighted signals? Curious what thresholds other people have landed on.</em></p>
<hr />
<p><em>Originally published at</em> <a href="https://webdecoy.com/blog/how-webdecoy-wordpress-plugin-detects-bots/"><em>webdecoy.com</em></a><em>.</em></p>
<p><strong>Related reading:</strong></p>
<ul>
<li><p><a href="https://webdecoy.com/blog/proof-of-work-captcha-hashcash-stop-bots/">Proof-of-Work CAPTCHAs with Hashcash</a></p>
</li>
<li><p><a href="https://webdecoy.com/blog/headless-browser-detection-playwright-puppeteer-selenium/">Headless Browser Detection: Playwright, Puppeteer, Selenium</a></p>
</li>
<li><p><a href="https://webdecoy.com/blog/mitre-attack-honeypot-mapping-threat-detection/">Mapping Honeypot Detections to MITRE ATT&amp;CK</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Bot Detection False Positives: How to Actually Test Accuracy]]></title><description><![CDATA[The fastest way to lose confidence in bot protection is not to miss a bot. It is to block a real customer.
A missed scraper costs bandwidth or content. A blocked customer costs a sale, a support escal]]></description><link>https://webdecoy.hashnode.dev/bot-detection-false-positives-how-to-actually-test-accuracy</link><guid isPermaLink="true">https://webdecoy.hashnode.dev/bot-detection-false-positives-how-to-actually-test-accuracy</guid><category><![CDATA[Security]]></category><category><![CDATA[Testing]]></category><category><![CDATA[Devops]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Chris Portscheller]]></dc:creator><pubDate>Sun, 13 Sep 2026 15:20:08 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aa6b18e99a13646db1b5cfc/01d1b1f8-aa43-43b1-81f1-51bc9501a992.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The fastest way to lose confidence in bot protection is not to miss a bot. <strong>It is to block a real customer.</strong></p>
<p>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: <em>what happens to real people when this policy leaves the dashboard and starts controlling traffic?</em></p>
<h2>A false positive is a business event</h2>
<p>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:</p>
<table>
<thead>
<tr>
<th>Route</th>
<th>Possible false-positive cost</th>
</tr>
</thead>
<tbody><tr>
<td>Public article</td>
<td>One page view is challenged or delayed</td>
</tr>
<tr>
<td>Login</td>
<td>A customer cannot reach their account</td>
</tr>
<tr>
<td>Password reset</td>
<td>A locked-out user cannot recover access</td>
</tr>
<tr>
<td>Checkout</td>
<td>Revenue is interrupted at the point of purchase</td>
</tr>
<tr>
<td>Public API</td>
<td>A partner integration begins failing</td>
</tr>
<tr>
<td>Account creation</td>
<td>A legitimate prospect cannot start a trial</td>
</tr>
</tbody></table>
<p><strong>An aggregate false-positive rate hides these differences.</strong> A vendor can report a low global rate while causing concentrated damage on one browser, one mobile network, or one high-value route.</p>
<p>So the useful question is not "what is the false-positive rate?" It is: <em>how many known-human sessions did this rule challenge or block on each protected route, and what happened next?</em></p>
<h2>Accuracy is usually the wrong headline metric</h2>
<p>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 <strong>a large accuracy percentage can describe a weak detector.</strong></p>
<p>Suppose a site receives 100,000 requests:</p>
<ul>
<li><p>99,000 are legitimate</p>
</li>
<li><p>1,000 are automated attacks</p>
</li>
<li><p>The detector catches 900 attacks</p>
</li>
<li><p>It also flags 200 legitimate requests</p>
</li>
</ul>
<p>The detector is <strong>99.7% accurate.</strong> That sounds excellent. It is also blocking or challenging 200 real requests, missing 100 attacks, and producing a bot verdict that is <strong>wrong almost one time in six.</strong></p>
<p>Use a small set of metrics together instead:</p>
<pre><code class="language-text">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
</code></pre>
<p>In the example: precision 81.8%, recall 90%, false-positive rate ~0.2%. At one million legitimate requests per day, <strong>that last number is about 2,000 customer requests per day receiving the wrong treatment.</strong></p>
<p><a href="https://developers.google.com/machine-learning/crash-course/classification/accuracy-precision-recall">Google's classification metrics guide</a> 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.</p>
<p>Which usually leads to three operating goals:</p>
<ul>
<li><p><strong>Hard blocks favor precision.</strong> The evidence should be strong enough that a human is very unlikely to match it.</p>
</li>
<li><p><strong>Challenges balance precision and recall.</strong> They provide a recovery path for ambiguous traffic.</p>
</li>
<li><p><strong>Monitoring favors recall.</strong> A broad signal is fine when a person or later rule reviews it before enforcement.</p>
</li>
</ul>
<p><strong>One score should not control all three actions.</strong></p>
<h2>Build a benchmark that resembles production</h2>
<p>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.</p>
<h3>Known-human traffic</h3>
<p>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 <em>independent evidence</em> that a real person completed a meaningful action.</p>
<p>Keep the sample representative across:</p>
<ul>
<li><p>Desktop and mobile browsers</p>
</li>
<li><p>Older devices and slow connections</p>
</li>
<li><p>Corporate networks, universities, and carrier-grade NAT</p>
</li>
<li><p>VPNs and privacy tools your customers actually use</p>
</li>
<li><p>Assistive technology and keyboard-only navigation</p>
</li>
<li><p>Logged-in customers, anonymous visitors, and partner users</p>
</li>
<li><p>Every route where the policy may eventually enforce</p>
</li>
</ul>
<p><strong>If the known-human set contains only employees on recent MacBooks, the benchmark is measuring employee laptops, not customers.</strong></p>
<h3>Known automation</h3>
<p>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.</p>
<p>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.</p>
<h3>Unknown traffic</h3>
<p>Leave genuinely ambiguous traffic labeled unknown. <strong>Do not call every session that failed to convert a bot, and do not call every session that passed a JavaScript check human.</strong> Those shortcuts make the detector's own assumptions part of its ground truth.</p>
<h2>Freeze the policy during each test</h2>
<p>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.</p>
<p>If thresholds change halfway through a test without a version marker, your final precision number combines two different systems.</p>
<pre><code class="language-json">{
  "policy_version": "checkout-2026-09-01.1",
  "route_group": "checkout",
  "score": 72,
  "would_action": "challenge",
  "signals": ["headless_mismatch", "velocity_anomaly"],
  "identity": "unverified",
  "outcome": "purchase_completed"
}
</code></pre>
<p><strong>The important field is</strong> <code>outcome</code><strong>.</strong> Without it you can count detections but cannot tell whether a proposed action would have interrupted a customer.</p>
<h2>Start in shadow mode</h2>
<p>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.</p>
<p>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:</p>
<ol>
<li><p>How many sessions would have been allowed, challenged, or blocked?</p>
</li>
<li><p>How many would-block sessions later logged in, purchased, submitted a valid form, or called an authenticated API?</p>
</li>
<li><p>Which rules contribute most of the false-positive candidates?</p>
</li>
<li><p>Are errors concentrated by browser, device, geography, ASN, customer, or integration?</p>
</li>
<li><p>How much attack traffic would each threshold miss?</p>
</li>
</ol>
<p>Don't declare victory after a quiet afternoon. The sample should span weekday and weekend behavior, billing cycles, product launches, and campaigns.</p>
<h3>The rule of three</h3>
<p><strong>The absence of an observed false positive is not proof the true rate is zero.</strong> A useful rough check: if a test observes zero errors in <code>N</code> independent known-human sessions, the upper edge of a rough 95% confidence interval is about <code>3/N</code>.</p>
<p>Zero errors in 1,000 sessions only supports a rate below roughly <strong>0.3%</strong>. 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.</p>
<h2>Test low-volume attacks without fooling yourself</h2>
<p>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.</p>
<ul>
<li><p><strong>Extend the observation window.</strong> Collect enough normal traffic to see rare customer conditions, and enough attack traffic to be more than a one-day anecdote.</p>
</li>
<li><p><strong>Replay known attacks.</strong> 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.</p>
</li>
<li><p><strong>Run controlled red-team traffic.</strong> Throttle your own automation to the rate a real attacker would use. Use realistic sessions and route order instead of hammering one endpoint.</p>
</li>
<li><p><strong>Measure evidence, not just volume.</strong> 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. <strong>Rate should be one signal, not the whole detector.</strong></p>
</li>
</ul>
<h2>Verify good bots before you allow them</h2>
<p>A crawler allowlist reduces false positives only if it <em>verifies identity</em>. Matching <code>Googlebot</code> in a User-Agent creates a bypass, because any client can send the same text — <a href="https://developers.google.com/search/docs/crawling-indexing/googlebot">Google's own documentation</a> warns the User-Agent is commonly spoofed and recommends verifying via published IP ranges or reverse DNS with forward confirmation.</p>
<pre><code class="language-text">Declared identity
    -&gt; verify source or signature
        -&gt; verified:    apply the crawler or partner policy
        -&gt; failed:      treat as impersonation evidence
        -&gt; unavailable: keep unverified, avoid claiming certainty
</code></pre>
<p>Don't turn a failed lookup into an automatic block if the source data may be stale. Record <em>why</em> verification failed and choose a route-appropriate fallback — public content can often fail open, sensitive APIs may require a service token.</p>
<h2>Shared IP addresses break simple enforcement</h2>
<p><strong>An IP address is a network location, not a person.</strong> 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.</p>
<p>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.</p>
<p>Treat IP reputation and request rate as <em>context</em>. Correlate with session evidence, authentication state, route, TLS and browser characteristics, and behavior. When uncertainty remains, challenge the session rather than blocking the address.</p>
<p>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 <strong>"the attack stopped while the other sessions continued."</strong></p>
<h2>Move from shadow mode to a canary</h2>
<p>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.</p>
<ol>
<li><p><strong>Shadow everything.</strong> Collect proposed actions and business outcomes.</p>
</li>
<li><p><strong>Canary challenges.</strong> Challenge a small percentage of ambiguous sessions on one route group.</p>
</li>
<li><p><strong>Enforce high-confidence evidence.</strong> Block deterministic abuse, or sessions that repeatedly fail the recovery path.</p>
</li>
<li><p><strong>Expand by route.</strong> Increase exposure only after guardrails stay healthy.</p>
</li>
<li><p><strong>Keep a control group.</strong> A small untreated cohort keeps conversion and support impact measurable.</p>
</li>
</ol>
<p><strong>Start with an action that can recover.</strong> 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.</p>
<p>Define rollback triggers <em>before</em> the canary begins:</p>
<ul>
<li><p>Login success falls beyond the agreed tolerance</p>
</li>
<li><p>Checkout completion drops relative to control</p>
</li>
<li><p>Challenge abandonment rises for one browser or device class</p>
</li>
<li><p>Support contacts mention access failures</p>
</li>
<li><p>A major customer or partner appears in the would-block cohort</p>
</li>
<li><p>Latency exceeds the route's budget</p>
</li>
</ul>
<p>If a trigger fires, return the cohort to monitor mode, preserve the evidence, and investigate the contributing rule. <strong>A rollback is a successful safety mechanism, not a failed launch.</strong></p>
<h2>Measure conversion impact directly</h2>
<p>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.</p>
<p>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.</p>
<p>Compare the canary with its <strong>concurrent</strong> 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.</p>
<p>And segment the results. <strong>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.</strong></p>
<h2>Set thresholds from cost, not confidence theater</h2>
<p>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.</p>
<table>
<thead>
<tr>
<th>Decision</th>
<th>Evidence standard</th>
<th>Typical action</th>
</tr>
</thead>
<tbody><tr>
<td>Broad anomaly</td>
<td>Useful for investigation, weak identity</td>
<td>Log</td>
</tr>
<tr>
<td>Several independent suspicious signals</td>
<td>Likely automation, meaningful uncertainty</td>
<td>Challenge or rate-limit</td>
</tr>
<tr>
<td>Verified trusted automation</td>
<td>Proven operator or partner identity</td>
<td>Allow under explicit policy</td>
</tr>
<tr>
<td>Deterministic abuse evidence</td>
<td>Decoy interaction, valid attack signature, repeated failed proof</td>
<td>Block with expiry</td>
</tr>
</tbody></table>
<p>Review thresholds after browser releases, mobile app updates, WAF changes, major customer onboarding, and attacker shifts. <strong>A benchmark is not a certificate that lasts forever.</strong> It is a repeatable process for finding regressions before customers do.</p>
<h2>Production readiness checklist</h2>
<ul>
<li><p>[ ] Known-human traffic covers important browsers, devices, networks, and customers</p>
</li>
<li><p>[ ] Known automation includes trusted crawlers, partners, monitors, and hostile test clients</p>
</li>
<li><p>[ ] Unknown traffic remains unknown rather than forced into a convenient label</p>
</li>
<li><p>[ ] Every verdict records the policy version and contributing evidence</p>
</li>
<li><p>[ ] Precision, recall, and false-positive rate are reported together</p>
</li>
<li><p>[ ] Metrics are broken down by route and customer-impact level</p>
</li>
<li><p>[ ] Shared-IP and corporate-proxy scenarios are included</p>
</li>
<li><p>[ ] Trusted bots are verified rather than matched by User-Agent alone</p>
</li>
<li><p>[ ] Shadow mode connects would-block decisions to product outcomes</p>
</li>
<li><p>[ ] Canary and control cohorts are stable and comparable</p>
</li>
<li><p>[ ] Challenges provide a recovery path for ambiguous traffic</p>
</li>
<li><p>[ ] Hard blocks expire and can be reversed quickly</p>
</li>
<li><p>[ ] Rollback triggers are written before enforcement starts</p>
</li>
<li><p>[ ] Conversion, support, and latency guardrails are monitored</p>
</li>
</ul>
<p>If several answers are no, keep the system in shadow mode. <strong>More traffic is not going to make an unmeasured policy safer.</strong></p>
<h2>The benchmark is the product</h2>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p><em>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.</em></p>
<hr />
<p><em>Originally published at</em> <a href="https://webdecoy.com/blog/bot-detection-false-positives-testing-benchmark/"><em>webdecoy.com</em></a><em>.</em></p>
<p><strong>Related reading:</strong></p>
<ul>
<li><p><a href="https://webdecoy.com/blog/browser-fingerprinting-2026-what-still-works/">Browser Fingerprinting 2026: What Still Works</a></p>
</li>
<li><p><a href="https://webdecoy.com/blog/headless-browser-detection-playwright-puppeteer-selenium/">Headless Browser Detection: Playwright, Puppeteer, Selenium</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[AI Agent Authentication in 2026: Web Bot Auth, ARD & OAuth]]></title><description><![CDATA[AI agent authentication is not one protocol. It is a stack.
An agent may need to discover a tool, prove which workload is running, authenticate an HTTP request, show that a user delegated authority, a]]></description><link>https://webdecoy.hashnode.dev/ai-agent-authentication-in-2026-web-bot-auth-ard-oauth</link><guid isPermaLink="true">https://webdecoy.hashnode.dev/ai-agent-authentication-in-2026-web-bot-auth-ard-oauth</guid><category><![CDATA[AI]]></category><category><![CDATA[Security]]></category><category><![CDATA[authentication]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Chris Portscheller]]></dc:creator><pubDate>Sun, 13 Sep 2026 15:13:16 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aa6b18e99a13646db1b5cfc/a1abe379-a41e-4b63-b032-bd826bf2ede8.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>AI agent authentication is not one protocol. <strong>It is a stack.</strong></p>
<p>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 <em>different parts</em> of that sequence. Treating any one of them as the whole answer creates an identity gap.</p>
<p>That distinction got sharper in 2026. Google announced the open <a href="https://developers.googleblog.com/en/announcing-the-agentic-resource-discovery-specification/">Agentic Resource Discovery specification</a> for finding and verifying agentic capabilities, and a separate <a href="https://datatracker.ietf.org/doc/draft-klrc-aiagent-auth/">IETF Internet-Draft on AI agent authentication</a> proposed an architecture for agent credentials, delegated user authority, workload identity, authorization, and audit trails.</p>
<p>Neither replaces OAuth. Neither makes a signed bot trustworthy. Together they show what a serious agent identity architecture has to look like.</p>
<h2>Four layers, four questions</h2>
<table>
<thead>
<tr>
<th>Layer</th>
<th>The question it answers</th>
<th>Typical mechanism</th>
<th>What it does <strong>not</strong> prove</th>
</tr>
</thead>
<tbody><tr>
<td>Discovery</td>
<td>Where is the capability, and who published its metadata?</td>
<td>ARD catalogs, registries, trust metadata</td>
<td>That the caller is allowed to invoke it</td>
</tr>
<tr>
<td>Workload identity</td>
<td>Which running software workload is this?</td>
<td>WIMSE credentials, SPIFFE IDs and SVIDs, mTLS</td>
<td>Which user delegated authority</td>
</tr>
<tr>
<td>Request authentication</td>
<td>Did this HTTP request come from the claimed automated client?</td>
<td>Web Bot Auth, HTTP Message Signatures, mTLS</td>
<td>That the requested action is permitted</td>
</tr>
<tr>
<td>Delegated authorization</td>
<td>What may the agent do, for which audience, on whose behalf?</td>
<td>OAuth access tokens, token exchange, transaction tokens</td>
<td>That the agent will behave safely</td>
</tr>
</tbody></table>
<p>There is a fifth layer underneath all four: <strong>observability.</strong> 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.</p>
<h2>Why API keys and user-agent strings aren't enough</h2>
<p>Traditional web automation has two identity mechanisms: a <code>User-Agent</code> header that names the bot, and an API key that grants access.</p>
<p>The first is a claim anyone can copy. The second is usually <strong>both an identity credential and a bearer permission compressed into one long-lived secret.</strong> If that key leaks from a log, environment variable, container image, or agent transcript, whoever holds it inherits its authority.</p>
<p>AI agents make this worse, because one action involves several distinct principals:</p>
<ul>
<li><p><strong>The agent operator</strong> — owns or deploys the software.</p>
</li>
<li><p><strong>The running agent workload</strong> — needs its own stable identity and credentials.</p>
</li>
<li><p><strong>The delegating user or system</strong> — whose authority the agent may be exercising.</p>
</li>
<li><p><strong>The resource owner</strong> — decides what that combination may do.</p>
</li>
</ul>
<p>Collapsing those into one API key destroys the information an authorization engine needs. It also produces an audit record that says only <em>"the key did it"</em> — not which workload ran, who authorized it, what constraints applied, or where authority changed hands.</p>
<p>The July 2026 IETF draft starts from a useful premise: <strong>agents are workloads.</strong> They should receive cryptographic credentials at runtime, authenticate as themselves, carry delegated authority separately, and preserve both identities through the call chain.</p>
<h2>Layer 1: ARD discovers capabilities before invocation</h2>
<p><a href="https://agenticresourcediscovery.org/">Agentic Resource Discovery</a> addresses a problem that appears <em>before</em> 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?</p>
<p>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.</p>
<p><strong>The boundary matters.</strong> ARD is deliberately pre-invocation infrastructure. It can answer:</p>
<ul>
<li><p>Which domain claims this capability?</p>
</li>
<li><p>Where is its endpoint and protocol description?</p>
</li>
<li><p>What trust material did the publisher provide?</p>
</li>
</ul>
<p>It cannot answer:</p>
<ul>
<li><p>Is this calling process the agent it claims to be?</p>
</li>
<li><p>Did a user authorize this transaction?</p>
</li>
<li><p>Is the requested action within policy?</p>
</li>
</ul>
<p>Put differently: <strong>verified discovery metadata is not a runtime access token.</strong> It establishes a trusted starting point; the resource must still authenticate and authorize the caller.</p>
<p>ARD is still evolving — the <a href="https://github.com/ards-project/ard-spec">spec repository</a> described v0.91 as an evolving specification. Version your catalogs and don't treat today's fields as permanently fixed.</p>
<h2>Layer 2: workload identity proves which agent is running</h2>
<p>After discovery, the caller needs a real identity. The strongest model is not "read a secret from an environment variable." It is <strong>"attest this runtime and issue a short-lived credential to the workload that passed attestation."</strong></p>
<p>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.</p>
<p><a href="https://spiffe.io/docs/latest/spiffe-about/overview/">SPIFFE</a> 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.</p>
<p>The architectural requirements that matter:</p>
<ul>
<li><p>Give the agent a stable workload identifier.</p>
</li>
<li><p>Provision primary credentials at runtime.</p>
</li>
<li><p>Prefer short-lived, automatically rotated credentials.</p>
</li>
<li><p>Keep credentials out of source code, images, prompts, and static config.</p>
</li>
<li><p><strong>Authenticate the agent independently from the user it represents.</strong></p>
</li>
</ul>
<p>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.</p>
<h2>Layer 3: Web Bot Auth proves an automated HTTP caller</h2>
<p>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.</p>
<p><a href="https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth-architecture/">Web Bot Auth</a> profiles <a href="https://www.rfc-editor.org/rfc/rfc9421">RFC 9421 HTTP Message Signatures</a> so automated clients sign request components and servers verify using public keys associated with the operator.</p>
<p>A valid signature proves:</p>
<ul>
<li><p>The request was signed by the holder of the corresponding private key.</p>
</li>
<li><p>Protected components were not changed after signing.</p>
</li>
<li><p>The signature was created within its validity window.</p>
</li>
<li><p>The identity claim ties to an operator-controlled key.</p>
</li>
</ul>
<p>It does <strong>not</strong> prove:</p>
<ul>
<li><p>The agent is benevolent.</p>
</li>
<li><p>The operator has a business relationship with the site.</p>
</li>
<li><p>A user consented to the requested action.</p>
</li>
<li><p>The request should bypass rate limits or bot controls.</p>
</li>
<li><p>The signer is entitled to read, purchase, modify, or delete anything.</p>
</li>
</ul>
<p>That last distinction is the most important one in this whole article: <strong>authentication is evidence for an authorization decision, not the decision itself.</strong></p>
<p>A practical note on deployment: <strong>most automated traffic is still unsigned.</strong> 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 <em>unproven</em>, not as identity.</p>
<h2>Layer 4: OAuth carries authority, including user delegation</h2>
<p>Once an agent is authenticated, a resource still needs to know what it may do. Two materially different cases:</p>
<p><strong>The agent acts on its own authority.</strong> It obtains a narrowly scoped access token through a machine-to-machine grant, authenticating to the authorization server with its <em>workload credential</em> — 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.</p>
<p><strong>The agent acts for a user.</strong> 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.</p>
<p>The resource should be able to answer both:</p>
<ol>
<li><p>Which agent exercised the authority?</p>
</li>
<li><p>Which user or system delegated it?</p>
</li>
</ol>
<p>That separation limits the confused-deputy problem, and it prevents an audit record from falsely attributing an automated action directly to a human.</p>
<p>For multi-service workflows, <a href="https://www.rfc-editor.org/rfc/rfc8693">OAuth 2.0 Token Exchange (RFC 8693)</a> lets a security token service swap one token for another with a different audience or reduced authority. <strong>Every hop should narrow or preserve authority; no downstream tool should silently gain a broader token than the initiating agent received.</strong> Current OAuth security guidance is consolidated in <a href="https://www.rfc-editor.org/rfc/rfc9700">RFC 9700</a> — follow it rather than reviving weaker historical patterns because the client happens to be autonomous.</p>
<h2>An end-to-end flow</h2>
<ol>
<li><p><strong>Discover.</strong> Query an ARD catalog; receive a candidate capability, endpoint, protocol description, trust metadata.</p>
</li>
<li><p><strong>Verify discovery.</strong> Validate the publisher before connecting. Discovery results are untrusted input until verified.</p>
</li>
<li><p><strong>Attest the workload.</strong> The runtime proves where and how the agent is running; the identity system issues a short-lived credential bound to its stable identifier.</p>
</li>
<li><p><strong>Authenticate the connection or request.</strong> 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.</p>
</li>
<li><p><strong>Obtain authority.</strong> An OAuth token for the agent's own authority, or for explicitly delegated user authority — with the two kept distinguishable.</p>
</li>
<li><p><strong>Authorize at the resource.</strong> Evaluate agent, subject, audience, scope, action, tenant, risk, and policy. A valid credential is necessary evidence, not an automatic allow.</p>
</li>
<li><p><strong>Downscope each hop.</strong> Token exchange gives downstream tools only what they need.</p>
</li>
<li><p><strong>Record and monitor.</strong> Logs connect agent identifier, delegating subject, token identifier, policy decision, tool call, and side effect.</p>
</li>
<li><p><strong>Revoke independently.</strong> Workload, key, token, user grant, and discovered resource each revocable on their own.</p>
</li>
</ol>
<h2>Failure modes this architecture should stop</h2>
<p><strong>A valid identity becomes an allowlist bypass.</strong> 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.</p>
<p><strong>The user and agent collapse into one subject.</strong> 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.</p>
<p><strong>A bearer token gains power as it moves downstream.</strong> Don't forward the broad original token through every tool call. Exchange or downscope per audience and action.</p>
<p><strong>Discovery metadata is trusted like an access decision.</strong> Catalog content can point an agent at an attacker-controlled endpoint. Verify publisher trust metadata, constrain redirects and egress, apply normal SSRF defenses.</p>
<p><strong>Static secrets are cloned with the agent.</strong> If every replica shares one API key, you cannot distinguish instances or revoke one compromised runtime.</p>
<p><strong>The audit trail loses authorization context.</strong> Logging only the final API call is not enough. Preserve agent identity, delegating principal, token audience and scope, policy version, decision, and result.</p>
<h2>Implementation checklist</h2>
<ul>
<li><p>Assign every agent workload a stable identifier.</p>
</li>
<li><p>Issue credentials at runtime after workload attestation.</p>
</li>
<li><p>Make credentials short lived and automatically rotated.</p>
</li>
<li><p>Never bake API keys or OAuth client secrets into agent images, code, or prompts.</p>
</li>
<li><p>Keep the agent's identity separate from the user's delegated identity.</p>
</li>
<li><p>Use OAuth scopes, audience restrictions, and expiry as real policy boundaries.</p>
</li>
<li><p>Downscope authority at each tool or service hop.</p>
</li>
<li><p>Verify HTTP signatures over the components that matter, and enforce replay bounds.</p>
</li>
<li><p>Treat discovery results as untrusted until trust metadata is verified.</p>
</li>
<li><p>Do not equate a valid signature with safe behavior or permission.</p>
</li>
<li><p>Log the agent, delegating subject, authorization decision, tool call, and side effect together.</p>
</li>
<li><p>Design independent revocation for workload credentials, signing keys, OAuth grants, and discovered resources.</p>
</li>
<li><p>Support unsigned traffic without silently upgrading a claim into verified identity.</p>
</li>
</ul>
<h2>What's stable, what's still emerging</h2>
<ul>
<li><p><strong>HTTP Message Signatures</strong> — standardized, RFC 9421.</p>
</li>
<li><p><strong>OAuth Token Exchange</strong> — standardized, RFC 8693. Security BCP is RFC 9700.</p>
</li>
<li><p><strong>SPIFFE</strong> — deployed workload identity infrastructure with published specs and implementations.</p>
</li>
<li><p><strong>Web Bot Auth</strong> — IETF Internet-Draft built on RFC 9421.</p>
</li>
<li><p><strong>AI agent authentication architecture</strong> — an <em>individual</em> Internet-Draft, not an adopted standard. <code>-03</code> updated July 2026, work in progress.</p>
</li>
<li><p><strong>ARD</strong> — open, evolving specification. Expect iteration.</p>
</li>
</ul>
<p>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.</p>
<h2>The bottom line</h2>
<ul>
<li><p><strong>ARD</strong> tells an agent what exists and where to find it.</p>
</li>
<li><p><strong>Workload identity</strong> proves which software instance is running.</p>
</li>
<li><p><strong>Web Bot Auth</strong> proves who signed an automated web request.</p>
</li>
<li><p><strong>OAuth</strong> says what that agent may do and on whose behalf.</p>
</li>
<li><p><strong>Observability</strong> proves what happened afterward.</p>
</li>
</ul>
<p>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.</p>
<p><em>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?</em></p>
<hr />
<p><em>Originally published at</em> <a href="https://webdecoy.com/blog/ai-agent-authentication-web-bot-auth-ard-oauth/"><em>webdecoy.com</em></a><em>.</em></p>
<p><strong>Related reading:</strong></p>
<ul>
<li><p><a href="https://webdecoy.com/blog/web-bot-auth-google-signed-crawlers/">Web Bot Auth: Google Now Signs Its Crawlers</a></p>
</li>
<li><p><a href="https://webdecoy.com/blog/signed-agents-web-bot-auth-coming-schism/">Signed Agents and the Coming Web Identity Schism</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Browser Fingerprinting in 2026: What Still Works, What Doesn't]]></title><description><![CDATA[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 depende]]></description><link>https://webdecoy.hashnode.dev/browser-fingerprinting-in-2026-what-still-works-what-doesn-t</link><guid isPermaLink="true">https://webdecoy.hashnode.dev/browser-fingerprinting-in-2026-what-still-works-what-doesn-t</guid><category><![CDATA[Security]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[privacy]]></category><dc:creator><![CDATA[Chris Portscheller]]></dc:creator><pubDate>Sun, 13 Sep 2026 15:07:06 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aa6b18e99a13646db1b5cfc/b82d4639-389a-4f5c-8023-2976841d2e6f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>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.</p>
<p>If you're building or maintaining a fingerprinting system, <strong>a lot of the advice from even two years ago is obsolete.</strong> This is a technical audit of what still produces usable signal, what's been neutralized, and what emerged to replace the losses.</p>
<p>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.</p>
<h2>The Scoreboard</h2>
<p>Each technique rated on two axes — <strong>entropy</strong> (how much identifying information it produces) and <strong>durability</strong> (how resistant it is to spoofing and browser mitigation).</p>
<table>
<thead>
<tr>
<th>Technique</th>
<th>Entropy</th>
<th>Durability</th>
<th>Status in 2026</th>
</tr>
</thead>
<tbody><tr>
<td>User-Agent string</td>
<td>Very low</td>
<td>None</td>
<td>Dead. Frozen by Chrome 107+.</td>
</tr>
<tr>
<td>navigator.plugins</td>
<td>None</td>
<td>None</td>
<td>Dead. Returns empty array in Chrome.</td>
</tr>
<tr>
<td>navigator.platform</td>
<td>Very low</td>
<td>None</td>
<td>Dead. Frozen to generic values.</td>
</tr>
<tr>
<td>Canvas fingerprint</td>
<td>Medium</td>
<td>Medium</td>
<td>Degraded but usable. Noise in Firefox/Brave.</td>
</tr>
<tr>
<td>WebGL fingerprint</td>
<td>Medium-High</td>
<td>Medium-High</td>
<td>Still strong. Renderer strings remain diverse.</td>
</tr>
<tr>
<td>WebGL rendering</td>
<td>Medium</td>
<td>Medium</td>
<td>Works. GPU output is hard to standardize.</td>
</tr>
<tr>
<td>AudioContext</td>
<td>Medium</td>
<td>Medium</td>
<td>Works. Hardware timing differences persist.</td>
</tr>
<tr>
<td>Font enumeration</td>
<td>Low-Medium</td>
<td>Low</td>
<td>Declining. OS font standardization.</td>
</tr>
<tr>
<td>Screen/display props</td>
<td>Low</td>
<td>Low</td>
<td>Minimal entropy. Heavily spoofed.</td>
</tr>
<tr>
<td>Client Hints</td>
<td>Low</td>
<td>Low</td>
<td>Reduced by design.</td>
</tr>
<tr>
<td><strong>TLS fingerprint (JA4)</strong></td>
<td><strong>High</strong></td>
<td><strong>Very High</strong></td>
<td><strong>Strongest signal. Cannot be spoofed from JS.</strong></td>
</tr>
<tr>
<td>HTTP/2 settings</td>
<td>Medium</td>
<td>High</td>
<td>Underutilized. Good connection-level entropy.</td>
</tr>
<tr>
<td>TCP/IP stack</td>
<td>Low-Medium</td>
<td>High</td>
<td>Niche but durable.</td>
</tr>
</tbody></table>
<p>The trend is clear: <strong>JavaScript-accessible signals are eroding. Network-level signals are ascendant.</strong> The most durable techniques in 2026 operate below the browser's API surface, where privacy extensions and stealth plugins can't reach them.</p>
<h2>What's Dead</h2>
<p><strong>User-Agent string.</strong> Chrome killed it. Since Chrome 107 in late 2022 the string is frozen — the version number still increments, but OS version is pinned to <code>Windows NT 10.0</code>, 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.</p>
<p><code>navigator.plugins</code> <strong>/</strong> <code>navigator.mimeTypes</code><strong>.</strong> 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.</p>
<p><code>navigator.platform</code><strong>.</strong> Frozen to generic values like <code>Win32</code> regardless of actual architecture. The Client Hints replacement (<code>navigator.userAgentData.platform</code>) is <em>designed</em> to be lower entropy and gates detailed values behind a permission request.</p>
<h2>What's Degraded but Usable</h2>
<h3>Canvas fingerprinting</h3>
<p>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.</p>
<p>Per-browser reality in 2026:</p>
<ul>
<li><p><strong>Chrome</strong> — still consistent, device-specific output. No noise injection. Highest-fidelity target.</p>
</li>
<li><p><strong>Firefox</strong> — noise injection since 113 via <code>privacy.resistFingerprinting</code>. Off by default, on in strict privacy mode and private windows. When enabled, the same device produces a different hash on every page load.</p>
</li>
<li><p><strong>Safari</strong> — minimal canvas protection. Still stable.</p>
</li>
<li><p><strong>Brave</strong> — aggressively randomizes by default. Effectively useless against Brave users.</p>
</li>
</ul>
<pre><code class="language-javascript">function getCanvasFingerprint() {
  const canvas = document.createElement('canvas');
  canvas.width = 256;
  canvas.height = 256;
  const ctx = canvas.getContext('2d');

  ctx.textBaseline = 'top';
  ctx.font = '14px Arial';
  ctx.fillStyle = '#f60';
  ctx.fillRect(125, 1, 62, 20);
  ctx.fillStyle = '#069';
  ctx.fillText('Browser fingerprint', 2, 15);
  ctx.fillStyle = 'rgba(102, 204, 0, 0.7)';
  ctx.fillText('Browser fingerprint', 4, 17);

  // Geometric shapes for GPU-dependent rendering
  ctx.beginPath();
  ctx.arc(50, 50, 50, 0, Math.PI * 2, true);
  ctx.closePath();
  ctx.fill();

  return canvas.toDataURL();
}
</code></pre>
<p><strong>Verdict:</strong> still works on roughly 80% of browsers. Expect continued degradation.</p>
<h3>Font enumeration</h3>
<p>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 (<code>privacy.resistFingerprinting</code> returns a fixed list). Still distinguishes Windows from macOS from Linux. The days of font lists as a high-entropy identifier are over.</p>
<h3>Screen and display properties</h3>
<p>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.</p>
<h2>What Still Works</h2>
<h3>WebGL — the quiet workhorse</h3>
<p>Two levels. <strong>Parameter enumeration</strong> exposes hardware and driver information:</p>
<pre><code class="language-javascript">function getWebGLFingerprint() {
  const canvas = document.createElement('canvas');
  const gl = canvas.getContext('webgl');
  if (!gl) return null;

  const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');

  return {
    vendor: gl.getParameter(gl.VENDOR),
    renderer: gl.getParameter(gl.RENDERER),
    unmaskedVendor: debugInfo
      ? gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL)
      : null,
    unmaskedRenderer: debugInfo
      ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL)
      : null,
    maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE),
    maxViewportDims: gl.getParameter(gl.MAX_VIEWPORT_DIMS),
    extensions: gl.getSupportedExtensions(),
    shadingLanguageVersion: gl.getParameter(gl.SHADING_LANGUAGE_VERSION),
  };
}
</code></pre>
<p>The unmasked renderer string alone carries substantial entropy — <code>ANGLE (NVIDIA GeForce RTX 4070 Ti Direct3D11 vs_5_0 ps_5_0)</code> identifies a specific GPU model. <strong>Level 2</strong> is render output: drawing a 3D scene and reading back pixels, with higher variability than canvas because 3D pipelines differ more across GPU architectures.</p>
<p><strong>Why it's durable:</strong> 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 <em>visibly</em>.</p>
<p><strong>For bot detection specifically, WebGL is gold.</strong> Headless Chrome in a cloud VM reports the VM's virtual GPU — typically <code>Google SwiftShader</code> or <code>llvmpipe</code> — 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.</p>
<h3>AudioContext</h3>
<p>Exploits hardware-dependent differences in audio signal processing. Route an <code>OscillatorNode</code> through a <code>DynamicsCompressorNode</code>, read the output, and you get floating-point sample values that vary with the audio hardware and driver stack:</p>
<pre><code class="language-javascript">function getAudioFingerprint() {
  return new Promise((resolve) =&gt; {
    const context = new OfflineAudioContext(1, 44100, 44100);
    const oscillator = context.createOscillator();
    oscillator.type = 'triangle';
    oscillator.frequency.setValueAtTime(10000, context.currentTime);

    const compressor = context.createDynamicsCompressor();
    compressor.threshold.setValueAtTime(-50, context.currentTime);
    compressor.knee.setValueAtTime(40, context.currentTime);
    compressor.ratio.setValueAtTime(12, context.currentTime);
    compressor.attack.setValueAtTime(0, context.currentTime);
    compressor.release.setValueAtTime(0.25, context.currentTime);

    oscillator.connect(compressor);
    compressor.connect(context.destination);
    oscillator.start(0);

    context.startRendering().then((buffer) =&gt; {
      const data = buffer.getChannelData(0);
      let sum = 0;
      for (let i = 4500; i &lt; 5000; i++) sum += Math.abs(data[i]);
      resolve(sum);
    });
  });
}
</code></pre>
<p>Lower entropy than WebGL, but it's an <em>independent</em> 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.</p>
<h3>TLS fingerprinting (JA4)</h3>
<p>The biggest shift in fingerprinting since canvas was discovered. <strong>TLS fingerprinting doesn't operate in JavaScript at all.</strong> It analyzes the ClientHello sent during the HTTPS handshake — before page content loads, before JavaScript executes, before any browser API can be manipulated.</p>
<p>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: <strong>you cannot change your JA4 fingerprint from JavaScript.</strong> It's determined by the TLS library compiled into the client.</p>
<p>Which means:</p>
<ul>
<li><p>A Playwright bot claiming Chrome 126 but running an older Chromium has a JA4 that doesn't match real Chrome 126.</p>
</li>
<li><p>A Python <code>requests</code> session spoofing a Chrome User-Agent has a JA4 matching <code>urllib3</code>, not Chrome.</p>
</li>
<li><p>A BaaS platform running headless Chrome in the cloud has a JA4 matching their specific Chromium build — often months behind stable.</p>
</li>
</ul>
<p>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.</p>
<h3>HTTP/2 settings</h3>
<p>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.</p>
<h2>The Anti-Fingerprinting Landscape</h2>
<p><strong>Chrome (Privacy Sandbox).</strong> 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 <em>less unique</em>, not impossible.</p>
<p><strong>Firefox.</strong> The most granular controls. <code>privacy.resistFingerprinting</code> (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.</p>
<p><strong>Safari.</strong> Pragmatic — restricts some vectors (limiting <code>document.fonts</code>, reducing timer precision) but focuses mainly on cookie and storage partitioning.</p>
<p><strong>Brave.</strong> The most aggressive. Randomizes canvas, blocks WebGL renderer info, adds AudioContext noise, limits fonts. The goal is to make fingerprinting actively <em>unreliable</em>, not just lower entropy.</p>
<p>On the other side:</p>
<p><strong>Puppeteer Extra Stealth / Playwright stealth patches</strong> patch <code>navigator.webdriver</code>, spoof plugin arrays, override Chrome runtime properties. Increasingly they spoof <code>WEBGL_debug_renderer_info</code> to report a realistic GPU instead of SwiftShader. <strong>None of it affects TLS or network-level signals.</strong></p>
<p><strong>Browser-as-a-Service (Browserbase, Hyperbrowser)</strong> 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.</p>
<p><strong>Anti-detect browsers (Multilogin, GoLogin, Dolphin Anty)</strong> 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.</p>
<h2>Building a System in 2026</h2>
<p><strong>Layer 1 — network fingerprints (server-side).</strong> Collect at the proxy or load balancer:</p>
<pre><code class="language-plaintext">TLS ClientHello   → JA4 hash
HTTP/2 SETTINGS   → settings fingerprint
TCP/IP characteristics → OS fingerprint
</code></pre>
<p>This is the foundation because it cannot be manipulated from the client. It tells you what the client actually <em>is</em>, regardless of what it claims.</p>
<p><strong>Layer 2 — hardware fingerprints (JavaScript).</strong> WebGL renderer + parameters, AudioContext output, canvas rendering. Harder to spoof than software properties because they depend on physical components and low-level driver behavior.</p>
<p><strong>Layer 3 — behavioral fingerprints (JavaScript).</strong> 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.</p>
<h3>Cross-signal coherence is the whole game</h3>
<p>No single technique is sufficient. The value is in the combination, and specifically in <strong>coherence</strong>. A client claiming Chrome 126 on Windows 11 should have:</p>
<ul>
<li><p>a JA4 hash matching Chrome 126's TLS stack</p>
</li>
<li><p>a WebGL renderer matching a real Windows GPU (not SwiftShader)</p>
</li>
<li><p>AudioContext output consistent with Windows audio drivers</p>
</li>
<li><p>HTTP/2 settings matching Chrome's defaults</p>
</li>
<li><p>mouse movement with human-like jitter and velocity curves</p>
</li>
</ul>
<p>If any one of these contradicts the others, something is being spoofed. <strong>A real browser is internally consistent. A spoofed browser almost never is</strong>, because each spoofing mechanism operates independently and rarely accounts for cross-signal dependencies.</p>
<h2>The privacy tension</h2>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p><em>Anyone here still getting useful entropy out of canvas, or have you moved everything to the network layer?</em></p>
<hr />
<p><em>Originally published at</em> <a href="https://webdecoy.com/blog/browser-fingerprinting-2026-what-still-works/"><em>webdecoy.com</em></a><em>.</em></p>
<p><strong>Related reading:</strong></p>
<ul>
<li><p><a href="https://webdecoy.com/blog/ja4-fingerprinting-ai-scrapers-practical-guide/">JA4 Fingerprinting for AI Scraper Detection</a></p>
</li>
<li><p><a href="https://webdecoy.com/blog/headless-browser-detection-playwright-puppeteer-selenium/">Headless Browser Detection: Playwright, Puppeteer, Selenium</a></p>
</li>
<li><p><a href="https://webdecoy.com/blog/browser-as-a-service-detection-baas-ai-agents-2025/">How to Detect Browser-as-a-Service Scrapers</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Why CAPTCHAs Are Dead (And What Replaces Them in 2026)]]></title><description><![CDATA[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.]]></description><link>https://webdecoy.hashnode.dev/why-captchas-are-dead-and-what-replaces-them-in-2026</link><guid isPermaLink="true">https://webdecoy.hashnode.dev/why-captchas-are-dead-and-what-replaces-them-in-2026</guid><category><![CDATA[Security]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[captcha]]></category><dc:creator><![CDATA[Chris Portscheller]]></dc:creator><pubDate>Sun, 13 Sep 2026 15:00:17 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aa6b18e99a13646db1b5cfc/60d4206c-7fdf-48df-8185-5976db0079d3.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>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 <em>real users</em>?"</p>
<p>This is not a story about a particular CAPTCHA being broken. It's a story about <strong>a category that has lost its asymmetric advantage.</strong> 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.</p>
<h2>What a CAPTCHA Was Supposed to Do</h2>
<p>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:</p>
<ol>
<li><p>The challenge is hard for current machine learning.</p>
</li>
<li><p>The challenge is easy for typical humans.</p>
</li>
<li><p>The cost to generate the challenge is much lower than the cost to solve it.</p>
</li>
</ol>
<p>All three were approximately true for distorted-text CAPTCHAs in 2003. <strong>None are true for any visual CAPTCHA in 2026.</strong></p>
<p>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.</p>
<h2>The Solver Economy</h2>
<p>Here's the part that doesn't get talked about enough. <strong>CAPTCHAs are not defeated primarily by AI. They are defeated by markets that price AI at scale.</strong></p>
<p>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:</p>
<table>
<thead>
<tr>
<th>Challenge type</th>
<th>Price per 1,000 solves</th>
<th>Median solve time</th>
</tr>
</thead>
<tbody><tr>
<td>reCAPTCHA v2 (image grids)</td>
<td>$1.50 – $3.00</td>
<td>8 – 15 s</td>
</tr>
<tr>
<td>reCAPTCHA v3 (invisible)</td>
<td>$1.50 – $2.50</td>
<td>1 – 3 s</td>
</tr>
<tr>
<td>hCaptcha</td>
<td>$1.00 – $2.50</td>
<td>8 – 12 s</td>
</tr>
<tr>
<td>Cloudflare Turnstile</td>
<td>$1.50 – $3.00</td>
<td>4 – 10 s</td>
</tr>
<tr>
<td>Arkose Labs FunCaptcha</td>
<td>$3.00 – $7.00</td>
<td>15 – 30 s</td>
</tr>
<tr>
<td>Audio CAPTCHA</td>
<td>$2.00 – $4.00</td>
<td>15 – 25 s</td>
</tr>
</tbody></table>
<p>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 <strong>\(6,000/day in revenue against \)300 in solver cost.</strong></p>
<p>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.</p>
<h2>The Autopsy, By Variant</h2>
<p><strong>reCAPTCHA v2 image grids.</strong> 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% — <em>higher than the average human solve rate against the same puzzles.</em> The audio accessibility fallback is solved by Whisper with similar reliability.</p>
<p><strong>reCAPTCHA v3 invisible scoring.</strong> 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.</p>
<p><strong>hCaptcha.</strong> Functionally similar to v2 with a privacy-first marketing posture. Solved by the same providers at similar prices with similar success rates.</p>
<p><strong>Cloudflare Turnstile.</strong> 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.</p>
<p><strong>Arkose FunCaptcha.</strong> Long the holdout because of the 3D-rotation requirement. By 2025, depth-aware vision models trained on synthetic 3D renders started solving these reliably.</p>
<h2>The Shape of the Replacement</h2>
<p>Six independently useful components. Most production stacks combine three or four. <strong>None are silver bullets</strong> — the point is that combining cheap signals raises attacker cost faster than any single signal does.</p>
<h3>1. Behavioral biometrics</h3>
<p>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.</p>
<p>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.</p>
<p>Signals worth capturing, in rough order of cheapness:</p>
<ul>
<li><p>Mouse trajectory entropy and curvature</p>
</li>
<li><p>Inter-event timing distributions (keystroke, mouse move, scroll)</p>
</li>
<li><p>Field-fill order and time-on-form</p>
</li>
<li><p>Pointer move events between page load and first click</p>
</li>
<li><p>Touch vs mouse vs synthetic event detection (<code>event.isTrusted</code>)</p>
</li>
<li><p>Scroll velocity profiles</p>
</li>
<li><p>Focus and blur event sequences</p>
</li>
</ul>
<h3>2. Proof-of-work</h3>
<p>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.</p>
<p>The asymmetry is straightforward. <strong>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</strong>, or a real cloud bill in parallel. Worth nothing against someone targeting one account. Crippling for mass-volume operators.</p>
<pre><code class="language-javascript">async function solve(nonce, difficultyBits) {
  const target = (1n &lt;&lt; (256n - BigInt(difficultyBits)))
  for (let counter = 0; ; counter++) {
    const buf = new TextEncoder().encode(nonce + counter)
    const digest = await crypto.subtle.digest('SHA-256', buf)
    const hashInt = BigInt('0x' + [...new Uint8Array(digest)]
      .map(b =&gt; b.toString(16).padStart(2, '0')).join(''))
    if (hashInt &lt; target) return counter
  }
}
</code></pre>
<p>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 <strong>20 to 22 bits</strong>, roughly 100–400 ms on a five-year-old Android and well under a second on anything modern.</p>
<h3>3. Privacy Pass and Private Access Tokens</h3>
<p>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.</p>
<p>The catch in 2026 is coverage: well-supported on Apple platforms, partially on Cloudflare's network, effectively unsupported elsewhere. <strong>PAT is part of the stack, not the whole stack.</strong></p>
<h3>4. TLS and HTTP/2 fingerprinting</h3>
<p>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.</p>
<p>A POST that arrives with <code>User-Agent: Mozilla/5.0 ... Chrome/124</code> and a JA4 fingerprint that says "Go HTTP client" is automated. Full stop. No human Chrome ever produced that combination.</p>
<h3>5. Honeypot fields and decoy endpoints</h3>
<p>The oldest cheap trick still works for a useful slice of the threat. Two important caveats, though. Classic CSS-hidden honeypots (<code>display: none</code>) are increasingly <em>invisible</em> 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.</p>
<p>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.</p>
<h3>6. Server-side risk scoring</h3>
<p>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.</p>
<p><strong>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.</strong></p>
<h2>How They Combine</h2>
<p>A realistic 2026 stack on a high-value form:</p>
<pre><code class="language-plaintext">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)
</code></pre>
<p>What you do not do, in any layer, is show the user a visual challenge.</p>
<h2>An open-source implementation</h2>
<p><a href="https://github.com/WebDecoy/FCaptcha">FCaptcha</a> 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.</p>
<p>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.</p>
<p><strong>1. Run the server.</strong></p>
<pre><code class="language-bash">docker run -d -p 3000:3000 \
  -e FCAPTCHA_SECRET=your-secret \
  ghcr.io/webdecoy/fcaptcha
</code></pre>
<p>That gives you <code>POST /api/*</code> for verification and <code>GET /fcaptcha.js</code> for the widget. Two deployment notes worth knowing up front: the server <strong>fails closed without</strong> <code>FCAPTCHA_SECRET</code> (the public dev key is in the repo, so anything signed with it can be minted by anyone), and running <strong>more than one replica requires</strong> <code>REDIS_URL</code> so single-use tokens are single-use across the whole deployment rather than per process.</p>
<p><strong>2. Add the widget.</strong> Invisible mode auto-protects forms with no UI:</p>
<pre><code class="language-html">&lt;script src="https://your-server.com/fcaptcha.js"&gt;&lt;/script&gt;
&lt;script&gt;
  FCaptcha.configure({ serverUrl: 'https://your-server.com' })
  FCaptcha.invisible({ siteKey: 'your-site-key', autoScore: true })
&lt;/script&gt;
</code></pre>
<p><strong>3. Verify on the backend.</strong> A plain HTTP POST:</p>
<pre><code class="language-python">import requests

resp = requests.post(
    'https://your-server.com/api/token/verify',
    json={'token': token_from_form, 'secret': FCAPTCHA_SECRET},
).json()

# In FCaptcha, a LOW score means the request looks human.
if resp['valid'] and resp['score'] &lt; 0.5:
    return accept()
elif resp['valid'] and resp['score'] &lt; 0.8:
    return require_step_up()
else:
    return reject()
</code></pre>
<h2>What we don't claim</h2>
<p><strong>This does not "solve bot detection."</strong> 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.</p>
<p><strong>Behavioral telemetry can be replayed.</strong> 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 <em>current</em> page, does the keystroke entropy match the <em>current</em> user's history. Harder to replay convincingly. Not impossible.</p>
<p><strong>Proof-of-work is not free for clients.</strong> 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.</p>
<p><strong>No CAPTCHA replacement is GDPR-trivial.</strong> 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.</p>
<h2>A migration path</h2>
<p>If you're on reCAPTCHA today, the path that has worked for most teams:</p>
<ol>
<li><p><strong>Week 1</strong> — Ship behavioral telemetry <em>alongside</em> the existing CAPTCHA. Log scores, don't gate on them. Build a dashboard of score distributions for known-good and known-bad sessions.</p>
</li>
<li><p><strong>Weeks 2–3</strong> — 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.</p>
</li>
<li><p><strong>Week 4</strong> — Move the CAPTCHA into step-up-only mode. Default flow is invisible; only the gray band sees a challenge.</p>
</li>
<li><p><strong>Weeks 5–6</strong> — Replace the CAPTCHA step-up with a higher-difficulty PoW step-up, or soft MFA for accounts that have it.</p>
</li>
<li><p><strong>Week 7</strong> — Remove the CAPTCHA SDK entirely. Audit the privacy footprint reduction. Tell your conversions team.</p>
</li>
</ol>
<p>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.</p>
<h2>Where this goes next</h2>
<p><strong>Agent-driven traffic from real Chromiums.</strong> 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.</p>
<p><strong>Standardized client attestation.</strong> 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.</p>
<hr />
<p>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.</p>
<p>Either way: please stop making people click on traffic lights.</p>
<p><em>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.</em></p>
<hr />
<p><em>Originally published at</em> <a href="https://webdecoy.com/blog/why-captchas-are-dead-and-what-replaces-them-in-2026/"><em>webdecoy.com</em></a><em>.</em></p>
<p><strong>Related reading:</strong></p>
<ul>
<li><p><a href="https://webdecoy.com/blog/proof-of-work-captcha-hashcash-stop-bots/">Proof-of-Work CAPTCHAs with Hashcash</a></p>
</li>
<li><p><a href="https://webdecoy.com/blog/ja4-fingerprinting-ai-scrapers-practical-guide/">JA4 Fingerprinting for AI Scraper Detection</a></p>
</li>
<li><p><a href="https://webdecoy.com/blog/honeypot-traps-forms-buttons-endpoints-practical-guide/">Honeypot Traps: Forms, Buttons &amp; Endpoints</a></p>
</li>
<li><p><a href="https://webdecoy.com/blog/detecting-vision-based-ai-agents-operator-computer-use/">Detecting Vision-Based AI Agents</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Next.js Bot Detection: Block AI Crawlers at the Edge]]></title><description><![CDATA[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 bandwidt]]></description><link>https://webdecoy.hashnode.dev/next-js-bot-detection-block-ai-crawlers-at-the-edge</link><guid isPermaLink="true">https://webdecoy.hashnode.dev/next-js-bot-detection-block-ai-crawlers-at-the-edge</guid><category><![CDATA[Next.js]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Security]]></category><category><![CDATA[web scraping]]></category><dc:creator><![CDATA[Chris Portscheller]]></dc:creator><pubDate>Sun, 13 Sep 2026 14:47:39 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aa6b18e99a13646db1b5cfc/e1965dc5-edac-452a-821f-22ece5ccfa2c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>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.</p>
<p>The reflex is a ten-line user-agent block in <code>middleware.ts</code>. After you ship it the graph looks calmer for a day. Then it climbs again.</p>
<p>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.</p>
<p>We will also be honest about that last part, because most tutorials are not.</p>
<h2>The naive block, and exactly why it fails</h2>
<p>Almost every Next.js bot-blocking guide ends here:</p>
<pre><code class="language-ts">// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const BLOCKED = /GPTBot|ClaudeBot|PerplexityBot|Bytespider|CCBot|Google-Extended|Meta-ExternalAgent|Amazonbot/i

export function middleware(req: NextRequest) {
  const ua = req.headers.get('user-agent') || ''
  if (BLOCKED.test(ua)) {
    return new NextResponse('Forbidden', { status: 403 })
  }
  return NextResponse.next()
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
</code></pre>
<p>This works against a crawler that announces itself. GPTBot sends a user agent that says GPTBot, you match it, you return 403. Done.</p>
<p>The problem is that <strong>a user agent is a string the client chooses.</strong> 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.</p>
<p>So the honest framing: <strong>a user-agent block is a politeness filter.</strong> 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.</p>
<h2>Layer one: a real edge middleware</h2>
<p>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.</p>
<h3>Where middleware lives, and what <code>matcher</code> does</h3>
<p><code>middleware.ts</code> sits at the root of your project, or inside <code>src/</code>. 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.</p>
<p>The <code>matcher</code> is your most important performance setting. Without it, middleware runs on every asset, including static files Next.js already serves for free:</p>
<pre><code class="language-ts">export const config = {
  matcher: [
    // run on everything except Next internals and static files
    '/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)',
  ],
}
</code></pre>
<h3>Block, rate limit, or rewrite</h3>
<p><code>NextResponse</code> gives you three moves inside middleware:</p>
<pre><code class="language-ts">return new NextResponse('Forbidden', { status: 403 })          // block
return new NextResponse('Too Many Requests', { status: 429 })  // rate limit
return NextResponse.rewrite(new URL('/tarpit', req.url))       // send to a decoy
</code></pre>
<p>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 <code>Map</code>) will not hold when edge invocations do not share memory, so you reach for Upstash Redis or Vercel KV.</p>
<p>That is ongoing work, and it is the work <code>@webdecoy/nextjs</code> exists to remove:</p>
<pre><code class="language-bash">npm install @webdecoy/nextjs
</code></pre>
<pre><code class="language-ts">// middleware.ts
import { withWebDecoy } from '@webdecoy/nextjs'
import { rateLimit } from '@webdecoy/node'

export default withWebDecoy({
  apiKey: process.env.WEBDECOY_API_KEY!,
  // Built-in rules engine: no separate counter store to stand up.
  rules: [rateLimit({ max: 100, window: 60 })], // 100 requests per 60s
  // Skip work on paths that never need protection.
  skipPaths: ['/_next', '/favicon.ico', '/robots.txt'],
})

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
</code></pre>
<p>On every matched request this runs local analysis, applies your rules, and returns the right response on its own: a tripped <code>rateLimit</code> returns <code>429</code> with a <code>Retry-After</code> header, a deny rule returns <code>403</code>, and an allowed request continues with an <code>x-webdecoy-decision</code> header so your routes can read the verdict downstream. <code>onBlocked</code> and <code>onError</code> let you override, and <code>onError</code> fails open by default, so a hiccup in detection never locks out real users.</p>
<p>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 <strong>all things the client controls or can rotate.</strong> To catch a bot that lies about all of them, you need a signal it does not get to set: its own TLS handshake.</p>
<h2>The honest constraint nobody mentions</h2>
<p>Here is the part most Next.js guides skip, and it holds whether you hand-roll the gate or use a package: <strong>you cannot compute a TLS fingerprint inside</strong> <code>middleware.ts</code><strong>.</strong></p>
<p>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.</p>
<p>The catch on a platform like Vercel is that <strong>TLS terminates at the edge network before your middleware runs.</strong> 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 <code>node:tls</code>, 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.</p>
<p>This is not a flaw in Next.js. It is just where the layers sit. Put the fingerprint check where the handshake is visible:</p>
<ol>
<li><p><strong>Your own origin</strong>, when you self-host Next.js behind your own TLS termination (<code>next start</code> behind nginx or Caddy), where the proxy reads the handshake and forwards it as headers.</p>
</li>
<li><p><strong>A detection service</strong> that captures those handshake signals for you and returns a verdict your route handler can act on.</p>
</li>
</ol>
<p>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.</p>
<h2>Layer two: honeypot routes in the App Router</h2>
<p>A honeypot exploits a simple asymmetry: <strong>a real visitor never touches it, so any hit is suspicious by definition.</strong> The classic version is a hidden form field. For a Next.js crawler problem, a honeypot <em>route</em> is a better fit, because crawlers follow links and probe paths humans never click.</p>
<p>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:</p>
<pre><code class="language-tsx">// app/layout.tsx (excerpt)
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    &lt;html lang="en"&gt;
      &lt;body&gt;
        {children}
        {/* Invisible to humans, irresistible to link-scraping bots. */}
        &lt;a href="/api/trap" aria-hidden="true" tabIndex={-1}
           style={{ position: 'absolute', left: '-9999px' }}&gt;
          Account archive
        &lt;/a&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  )
}
</code></pre>
<p>Then the trap itself — a route handler that records the hit and responds blandly so the bot does not learn it was caught:</p>
<pre><code class="language-ts">// app/api/trap/route.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { flagClient } from '@/lib/threat'

export async function GET(req: NextRequest) {
  const ip = req.headers.get('x-forwarded-for')?.split(',')[0] ?? 'unknown'
  const ua = req.headers.get('user-agent') || ''

  // Record the hit. Anything reaching this route is presumed automated.
  await flagClient({ ip, ua, reason: 'honeypot:trap', score: 80 })

  // Respond like a boring empty resource. Do not reveal the trap.
  return new NextResponse(null, { status: 204 })
}
</code></pre>
<p>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:</p>
<pre><code class="language-ts">// inside middleware(), after the rate-limit check
import { isFlagged } from '@/lib/threat'

if (await isFlagged(ip)) {
  return NextResponse.rewrite(new URL('/tarpit', req.url))
}
</code></pre>
<p>The same pattern extends to fake API endpoints. A path like <code>/api/v1/users/export</code> that your real app never calls, but a scraper probing for data will, becomes a high-confidence trap.</p>
<p>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.</p>
<h2>Layer three: origin fingerprinting</h2>
<p>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.</p>
<p>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 <strong>claims to be Chrome but handshakes like curl.</strong></p>
<pre><code class="language-bash">npm install @webdecoy/node
</code></pre>
<pre><code class="language-ts">// app/api/checkout/route.ts
export const runtime = 'nodejs' // the Edge Runtime cannot see the handshake

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { WebDecoy } from '@webdecoy/node'

const webdecoy = new WebDecoy({
  apiKey: process.env.WEBDECOY_API_KEY!,
  enableTLSFingerprinting: true,
  threatScoreThreshold: 70, // block at a threat score of 70 or higher
})

export async function POST(req: NextRequest) {
  const result = await webdecoy.protect({
    method: req.method,
    path: new URL(req.url).pathname,
    ip: req.headers.get('x-forwarded-for')?.split(',')[0] ?? '0.0.0.0',
    user_agent: req.headers.get('user-agent') ?? '',
    headers: Object.fromEntries(req.headers),
    timestamp: Date.now(),
  })

  if (!result.allowed) {
    // result.detection carries decision, confidence (0 to 100), and bot_type.
    return NextResponse.json({ error: 'Request blocked' }, { status: 403 })
  }

  return NextResponse.json({ ok: true })
}
</code></pre>
<p><strong>One honest caveat about where each tier can run.</strong> 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.</p>
<p>Still on the Pages Router? The same package gives you a handler wrapper:</p>
<pre><code class="language-ts">// pages/api/checkout.ts
import { withBotProtection } from '@webdecoy/nextjs'
import type { NextApiRequest, NextApiResponse } from 'next'

async function handler(req: NextApiRequest, res: NextApiResponse) {
  res.json({ ok: true }) // req.webdecoy holds the detection result
}

export default withBotProtection(handler, {
  apiKey: process.env.WEBDECOY_API_KEY!,
  blockThreshold: 70,
})
</code></pre>
<h3>One decision from three signals</h3>
<p>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:</p>
<pre><code class="language-ts">// app/lib/decide.ts
type Signals = {
  edgeScreened: boolean   // passed the edge user-agent and rate gate
  honeypotHit: boolean    // touched a trap at any point
  threatScore: number     // 0 to 100, from result.detection.confidence
}

export function decide(s: Signals): 'allow' | 'challenge' | 'block' {
  if (s.honeypotHit) return 'block'           // touched a trap: automated by definition
  if (s.threatScore &gt;= 70) return 'block'     // handshake or local signals say automation
  if (s.threatScore &gt;= 40) return 'challenge' // suspicious, verify before trusting
  return 'allow'
}
</code></pre>
<p>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 <strong>at the same time</strong> — a much smaller and more expensive population than the flood you started with.</p>
<h2>Vercel BotID versus a self-hosted stack</h2>
<p>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.</p>
<p>BotID is a <strong>managed black box.</strong> 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.</p>
<p>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.</p>
<h2>Production checklist</h2>
<ul>
<li><p><strong>Scope the matcher.</strong> Never run middleware on <code>_next/static</code>, images, or other assets. Wasted compute, and it can break caching.</p>
</li>
<li><p><strong>Do not hard-block on user agent alone.</strong> Treat it as the cheap first pass, then escalate. A single spoofed header should not be enough to ban a visitor.</p>
</li>
<li><p><strong>Allow the good bots on purpose.</strong> 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.</p>
</li>
<li><p><strong>Watch your false positive rate.</strong> Log every block and challenge with the reason, and review the challenge bucket. If real users land there, loosen the thresholds in <code>decide()</code>.</p>
</li>
<li><p><strong>Fail open, not closed.</strong> 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.</p>
</li>
<li><p><strong>Measure the bill.</strong> 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.</p>
</li>
</ul>
<h2>Wrapping up</h2>
<p>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.</p>
<p>What is your site seeing from AI crawlers lately? Curious whether others are blocking outright or rate limiting and keeping the referral traffic.</p>
<hr />
<p><em>Originally published at</em> <a href="https://webdecoy.com/blog/nextjs-bot-detection-edge-middleware-block-ai-crawlers/"><em>webdecoy.com</em></a><em>.</em></p>
<p><strong>Related reading:</strong></p>
<ul>
<li><p><a href="https://webdecoy.com/blog/detect-ai-scrapers-gptbot-claudebot-perplexity/">Detect AI Scrapers: Block GPTBot, ClaudeBot &amp; More</a></p>
</li>
<li><p><a href="https://webdecoy.com/blog/ja4-fingerprinting-ai-scrapers-practical-guide/">JA4 Fingerprinting for AI Scraper Detection</a></p>
</li>
<li><p><a href="https://webdecoy.com/blog/honeypot-traps-forms-buttons-endpoints-practical-guide/">Honeypot Traps: Forms, Buttons &amp; Endpoints</a></p>
</li>
</ul>
]]></content:encoded></item></channel></rss>