<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: webdecoy</title>
    <description>The latest articles on DEV Community by webdecoy (@webdecoy).</description>
    <link>https://dev.to/webdecoy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F96509%2F575bf9aa-10c6-43d0-9624-7f180f9fabfe.png</url>
      <title>DEV Community: webdecoy</title>
      <link>https://dev.to/webdecoy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/webdecoy"/>
    <language>en</language>
    <item>
      <title>WebDecoy is now on Shopify: from bot signals to order review</title>
      <dc:creator>webdecoy</dc:creator>
      <pubDate>Sat, 03 Oct 2026 03:45:04 +0000</pubDate>
      <link>https://dev.to/webdecoy/webdecoy-is-now-on-shopify-from-bot-signals-to-order-review-5gc</link>
      <guid>https://dev.to/webdecoy/webdecoy-is-now-on-shopify-from-bot-signals-to-order-review-5gc</guid>
      <description>&lt;p&gt;&lt;strong&gt;WebDecoy is now approved and publicly listed on the Shopify App Store.&lt;/strong&gt; We built the app to connect suspicious storefront activity with the work merchants already do: inspect traffic, investigate repeat activity, and review orders before fulfillment.&lt;/p&gt;

&lt;p&gt;Disclosure: I’m the founder of WebDecoy. This post was prepared with AI assistance and checked against our published Shopify implementation and capability documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  From a visit to an order review
&lt;/h2&gt;

&lt;p&gt;An IP address alone is a poor reason to reject an order. A VPN is context, not proof of fraud. We combine browser behavior, server-derived request signals, IP enrichment, and interactions with hidden honeypot links and fields to make suspicious activity reviewable.&lt;/p&gt;

&lt;p&gt;The integration has three distinct paths:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Storefront:&lt;/strong&gt; a Theme App Extension supplies the app embed. Enable WebDecoy Bot Detection in the theme editor and save; there is no script to paste into theme files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checkout:&lt;/strong&gt; a separate Web Pixel reports supported checkout-funnel events. The theme embed does not run inside Shopify-hosted checkout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orders:&lt;/strong&gt; the storefront writes a session reference into the cart. When an order is created, matching detections and available actor context can contribute to its risk evidence.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Keeping those paths separate matters. A successful storefront test does not prove that checkout-pixel events arrive, and an order without the relevant session data cannot acquire a reliable browsing history just because the app is installed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What merchants can inspect
&lt;/h2&gt;

&lt;p&gt;Detection details show scores and supporting signals. Actor profiles group related activity using available fingerprint and session evidence, helping investigate repeat visits across IPs. These are investigative groupings, not guarantees of one unique person.&lt;/p&gt;

&lt;p&gt;Flagged Orders provides a review queue with status and notes. Supported plans add order tagging, native Shopify risk assessments with facts, and Shopify Flow triggers. The app also supplies order-risk metafields and an order-details extension.&lt;/p&gt;

&lt;p&gt;AI Crawlers and AI Referrals are separate reports: catalog retrieval and a shopper arriving from an AI assistant are different events. Referrals depend on identifiable source information; missing referrers cannot be reconstructed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify collection before enabling a response
&lt;/h2&gt;

&lt;p&gt;After installation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Choose a plan through Shopify and enable the theme app embed.&lt;/li&gt;
&lt;li&gt;Open &lt;strong&gt;Setup &amp;amp; health&lt;/strong&gt;, create a labeled storefront test, visit its link, and confirm that the detection arrived.&lt;/li&gt;
&lt;li&gt;Check checkout-pixel delivery separately, accounting for consent and browser conditions.&lt;/li&gt;
&lt;li&gt;Review detections and correlated orders before adding automation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Test detections are excluded from traffic metrics, order review, and enforcement. Setup checks should not become fake threats in production reports.&lt;/p&gt;

&lt;p&gt;Pro and Agency include Shopify Flow triggers. Our downloadable review and staff-notification templates start inactive when imported. Review the settings and enable them for new events when ready.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The order-created handler does not independently cancel orders or block checkout.&lt;/strong&gt; Cancellation is a separate Flow action that a merchant must deliberately configure and enable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this can observe
&lt;/h2&gt;

&lt;p&gt;A scraper that neither executes JavaScript nor touches a decoy can fetch public pages without appearing in storefront reports. This app does not deploy a WAF in front of Shopify-hosted checkout. We report the evidence our collection surfaces actually observe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the Shopify app
&lt;/h2&gt;

&lt;p&gt;The Free plan includes &lt;strong&gt;500 detections per month&lt;/strong&gt;. Starter is $59 USD/month, Pro $149, and Agency $449. Starter and Pro currently list 14-day trials; Shopify shows current pricing and trial availability before plan approval.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://webdecoy.com/shopify/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=shopify_launch" rel="noopener noreferrer"&gt;Watch the short demo and see the full setup guide&lt;/a&gt;. The demo uses a test store and seeded data, not customer results.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://apps.shopify.com/webdecoy-bot-protection" rel="noopener noreferrer"&gt;Install WebDecoy from the Shopify App Store&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What evidence would your team need before escalating an order for review?&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>security</category>
      <category>webdev</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>What's calling your Fastify API?</title>
      <dc:creator>webdecoy</dc:creator>
      <pubDate>Wed, 30 Sep 2026 18:49:00 +0000</pubDate>
      <link>https://dev.to/webdecoy/whats-calling-your-fastify-api-3kfh</link>
      <guid>https://dev.to/webdecoy/whats-calling-your-fastify-api-3kfh</guid>
      <description>&lt;p&gt;A product endpoint can serve your frontend, a customer integration, and a scraper through the same URL. Your page analytics may only show the first of those.&lt;/p&gt;

&lt;p&gt;Let's add request detection to a small Fastify API, watch a decoy request produce a verdict, and connect the results to a dashboard. We'll start in monitor mode so detection does not change the API's responses.&lt;/p&gt;

&lt;p&gt;We build WebDecoy. This example uses its Fastify plugin and was prepared with AI assistance. It is request monitoring, separate from our FCaptcha project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build a small API
&lt;/h2&gt;

&lt;p&gt;Use Node.js 22.12 or later. Create a folder and install the versions used here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;fastify-crawler-monitor
&lt;span class="nb"&gt;cd &lt;/span&gt;fastify-crawler-monitor
npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-exact&lt;/span&gt; fastify@5.12.5 @webdecoy/fastify@0.18.3 @webdecoy/node@0.18.3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create &lt;code&gt;server.mjs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Fastify&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fastify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;webdecoy&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@webdecoy/fastify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tripwire&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@webdecoy/node&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Fastify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;trustProxy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WEBDECOY_API_KEY&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;webdecoy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;monitor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;honeytoken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;skipPaths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;tripwire&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/.env&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;})],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addHook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;preHandler&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webdecoyDecision&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;routeOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;conclusion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;conclusion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;tripwire&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deniedBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tripwire&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;dashboardConfigured&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;products&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Field notebook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/.env&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Not found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;127.0.0.1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4310&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register WebDecoy before your routes and the hook that reads its decision. Fastify's &lt;a href="https://fastify.dev/docs/latest/Reference/Hooks/" rel="noopener noreferrer"&gt;hook documentation&lt;/a&gt; explains the lifecycle and scope.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;/.env&lt;/code&gt; handler returns a fixed 404. It never opens an environment file. We use a path that should not contain public application content to demonstrate a concrete rule.&lt;/p&gt;

&lt;p&gt;Automatic HTML trap injection is disabled here with &lt;code&gt;honeytoken: false&lt;/code&gt;. The sample is a JSON API, and we only need request observation for this walkthrough.&lt;/p&gt;

&lt;h2&gt;
  
  
  See a verdict while the API keeps responding
&lt;/h2&gt;

&lt;p&gt;Start the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node server.mjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From another terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; http://127.0.0.1:4310/api/products
curl &lt;span class="nt"&gt;-i&lt;/span&gt; http://127.0.0.1:4310/.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The product request returns HTTP 200 and a small catalog. The decoy path returns HTTP 404, while the terminal records:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;route: '/.env'
conclusion: 'DENY'
tripwire: true
dashboardConfigured: false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DENY&lt;/code&gt; is the rule verdict. Monitor mode lets the application continue, so the response is still the 404 defined by the handler. Seeing a refusal in the log does not mean the request was blocked.&lt;/p&gt;

&lt;p&gt;The example only logs a route template and a few decision fields. It does not print request headers, query strings, bodies, IP addresses, or API keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the detections in a dashboard
&lt;/h2&gt;

&lt;p&gt;The local tripwire works without an account. To collect cloud detections, &lt;a href="https://app.webdecoy.com/?utm_source=devto&amp;amp;utm_medium=tutorial&amp;amp;utm_campaign=fastify_crawler_monitor" rel="noopener noreferrer"&gt;create a WebDecoy account&lt;/a&gt; and create an API key for the site you want to observe.&lt;/p&gt;

&lt;p&gt;Put the key in a local &lt;code&gt;.env&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WEBDECOY_API_KEY=your_api_key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;.env&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt;. Keep the key on the server, including when you deploy. It does not belong in the frontend that calls this API.&lt;/p&gt;

&lt;p&gt;Stop and restart the server with the file loaded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;--env-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.env server.mjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then send the reserved test request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s1"&gt;'WebDecoy-Test/1.0'&lt;/span&gt; http://127.0.0.1:4310/api/products
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;strong&gt;Detections&lt;/strong&gt; in WebDecoy and look for the labeled &lt;strong&gt;Test&lt;/strong&gt; record. The endpoint should still return its catalog. The record verifies reporting; it does not represent an actual AI agent visiting your API.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dashboardConfigured: true&lt;/code&gt; only means the process received a key. It does not prove the key is valid or the report arrived. If the record is missing, check the selected site, the key, outbound connectivity, and reporting errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Move it into your application
&lt;/h2&gt;

&lt;p&gt;Keep your existing authentication, validation, authorization, and handlers. Add the plugin before the routes you want covered rather than replacing your server with this demo.&lt;/p&gt;

&lt;p&gt;The demo binds to loopback and trusts no proxy headers. For deployment, configure trusted proxies for your actual network using the &lt;a href="https://docs.webdecoy.com/sdk-plugins/node-sdk/" rel="noopener noreferrer"&gt;SDK's proxy settings&lt;/a&gt;. Do not trust arbitrary forwarded IP headers. Otherwise the traffic you are reviewing can be attributed to the wrong client.&lt;/p&gt;

&lt;p&gt;This plugin uses &lt;code&gt;preHandler&lt;/code&gt;. Requests rejected earlier in Fastify's lifecycle may never reach it. Likewise, requests served entirely by an upstream CDN do not reach this Node process. Use edge collection or access logs for those parts of the traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Work out which activity matters
&lt;/h2&gt;

&lt;p&gt;After deploying, look at the paths, repeated requests, classification, and supporting signals. A client using a familiar AI crawler name is not automatically that company's crawler. A legitimate customer integration is automated too, which is one reason to observe before enforcing a policy.&lt;/p&gt;

&lt;p&gt;Detections are not a complete request counter. Some requests can be allowed locally without cloud reporting, and caching can affect reporting frequency. Keep access logs for overall volume.&lt;/p&gt;

&lt;p&gt;Start with one useful question: which automated clients are repeatedly requesting the content this API exposes? Once you can answer that, you have a better basis for deciding what to allow, limit, or investigate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we checked
&lt;/h2&gt;

&lt;p&gt;The example was tested locally on Node.js 26.5.0 with Fastify 5.12.5 and WebDecoy packages 0.18.3. Checks covered the catalog response, the tripwire verdict with its unchanged 404, the skipped health route, and the reserved test request in monitor mode.&lt;/p&gt;

&lt;p&gt;No API key was used in those checks. Cloud delivery and real-crawler identification are not claimed as local test results.&lt;/p&gt;

</description>
      <category>node</category>
      <category>fastify</category>
      <category>tutorial</category>
      <category>security</category>
    </item>
    <item>
      <title>Who's crawling your Astro site?</title>
      <dc:creator>webdecoy</dc:creator>
      <pubDate>Wed, 30 Sep 2026 18:48:23 +0000</pubDate>
      <link>https://dev.to/webdecoy/whos-crawling-your-astro-site-18jh</link>
      <guid>https://dev.to/webdecoy/whos-crawling-your-astro-site-18jh</guid>
      <description>&lt;p&gt;Your Astro blog can serve a lot of readers without running much JavaScript. A crawler can read those same pages without running any at all.&lt;/p&gt;

&lt;p&gt;That makes browser analytics an incomplete place to look for automated visitors. A request for an article can reach your hosting provider even if the client never loads your analytics script.&lt;/p&gt;

&lt;p&gt;Let's put observation at the edge and check that it works with one request. This walkthrough is for a static Astro site on a custom domain already proxied through Cloudflare. You can keep your existing build and hosting setup.&lt;/p&gt;

&lt;p&gt;We build WebDecoy. This guide uses its Cloudflare edge sensor and dashboard, and was prepared with AI assistance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with where your pages are served
&lt;/h2&gt;

&lt;p&gt;Astro prerenders pages by default. Your hosting provider can serve the resulting HTML without starting an Astro server for each visit. Adding code to an Astro component does not make that code run whenever a crawler downloads the built file. Astro's &lt;a href="https://docs.astro.build/en/guides/on-demand-rendering/" rel="noopener noreferrer"&gt;rendering documentation&lt;/a&gt; explains the distinction.&lt;/p&gt;

&lt;p&gt;For this setup, requests take this path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Visitor → Cloudflare route → WebDecoy edge sensor → cached page or origin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sensor observes requests that reach its matching Worker route, including clients that do not execute page scripts. It does not require an Astro adapter or a change to server rendering.&lt;/p&gt;

&lt;p&gt;If you use a provider's default hostname rather than a Cloudflare-proxied custom domain, this particular setup will not cover it. Also check for existing Workers on the intended route. Do not replace a Worker that serves your application to make room for a sensor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connect the site you want to observe
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://app.webdecoy.com/?utm_source=devto&amp;amp;utm_medium=tutorial&amp;amp;utm_campaign=astro_crawler_monitor" rel="noopener noreferrer"&gt;Create a WebDecoy account&lt;/a&gt;, then select the site you want to monitor.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;strong&gt;Integrations → Cloudflare&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Connect with Cloudflare&lt;/strong&gt; and review the requested permissions.&lt;/li&gt;
&lt;li&gt;Open &lt;strong&gt;Edge Sensor&lt;/strong&gt; and select the zone and route for your site.&lt;/li&gt;
&lt;li&gt;Check which WebDecoy site will receive the detections before deploying.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In Cloudflare's &lt;strong&gt;DNS → Records&lt;/strong&gt;, confirm that your site's hostname is proxied, shown by the orange cloud. A DNS-only record can leave you with an installed Worker that never receives the requests you expect.&lt;/p&gt;

&lt;p&gt;Use the hostname people actually visit. A route for &lt;code&gt;example.com/*&lt;/code&gt; does not cover &lt;code&gt;www.example.com/*&lt;/code&gt;. Cloudflare's &lt;a href="https://developers.cloudflare.com/workers/configuration/routing/routes/" rel="noopener noreferrer"&gt;route documentation&lt;/a&gt; explains how patterns are matched.&lt;/p&gt;

&lt;p&gt;The managed installer can exclude asset paths such as &lt;code&gt;/_astro/*&lt;/code&gt; to reduce Worker invocations. Check these exclusions if you already have Workers serving assets: a route assigned to no Worker can affect more than the sensor. Worker usage is billed through your Cloudflare account under its current plan.&lt;/p&gt;

&lt;p&gt;The current managed setup supports one edge-sensor site per WebDecoy organization. The &lt;a href="https://docs.webdecoy.com/integrations/cloudflare-edge-sensor/" rel="noopener noreferrer"&gt;installation guide&lt;/a&gt; covers this and the permissions in detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploy, then prove a request arrives
&lt;/h2&gt;

&lt;p&gt;Select &lt;strong&gt;Deploy edge sensor&lt;/strong&gt;, then open &lt;strong&gt;Sensors&lt;/strong&gt; in WebDecoy. Look for the Edge Worker entry and confirm reporting is enabled.&lt;/p&gt;

&lt;p&gt;Sensing reports activity. Existing deny policies can still be enforced by the same Worker, so review any rules you already have if your goal is observation only.&lt;/p&gt;

&lt;p&gt;Now send a request to your own site's real hostname. Replace the example URL with yours:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;This request does not execute JavaScript. It uses a reserved user agent that asks the sensor to report a labeled test detection.&lt;/p&gt;

&lt;p&gt;Open &lt;strong&gt;Detections&lt;/strong&gt; for the same site and find the &lt;strong&gt;Test&lt;/strong&gt; record. That is your evidence that the request reached the sensor and the report reached WebDecoy. A successful page response or a successful deployment message alone does not establish both.&lt;/p&gt;

&lt;p&gt;The test label is intentional. It is not a claim that an AI crawler visited your site, and you should not include it in a screenshot as real crawler activity.&lt;/p&gt;

&lt;h2&gt;
  
  
  If the record is missing
&lt;/h2&gt;

&lt;p&gt;Check these before changing your Astro project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is the hostname proxied in Cloudflare DNS?&lt;/li&gt;
&lt;li&gt;Does the Worker route match the hostname and path used by curl?&lt;/li&gt;
&lt;li&gt;Is &lt;strong&gt;Reporting&lt;/strong&gt; on, and is the sensor build current?&lt;/li&gt;
&lt;li&gt;Are you looking at the WebDecoy site selected during deployment?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A request redirected from the bare domain to &lt;code&gt;www&lt;/code&gt; may take a different route. Check the response headers and test the final hostname directly. An upstream security rule can also stop traffic before your sensor sees it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the first real detections
&lt;/h2&gt;

&lt;p&gt;After the test, let ordinary traffic arrive. Start with the requested URLs. Are clients reading articles, following pagination, or probing paths such as &lt;code&gt;/.env&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;Then look at the crawler classification and the evidence supporting it. A user agent claiming to be GPTBot is a claim made by the client. Keep that separate from any verified identity shown in a record.&lt;/p&gt;

&lt;p&gt;Timing helps too. Several article requests spread across a day tell a different story from repeated probes within a few seconds. Neither pattern, on its own, tells you whether your content ended up in model training.&lt;/p&gt;

&lt;p&gt;The detections view is not a complete access log. The sensor selects automated-looking traffic for reporting, and sampling or limits can affect real-traffic counts. Keep hosting or CDN logs if you need a total request count.&lt;/p&gt;

&lt;p&gt;Once your labeled test appears, you can leave collection running and return to the dashboard to see what actually visits. You do not need to start blocking crawlers to make that useful.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>cloudflare</category>
      <category>tutorial</category>
      <category>security</category>
    </item>
    <item>
      <title>See which bots and AI crawlers are visiting your Angular site</title>
      <dc:creator>webdecoy</dc:creator>
      <pubDate>Tue, 29 Sep 2026 19:07:56 +0000</pubDate>
      <link>https://dev.to/webdecoy/see-which-bots-and-ai-crawlers-are-visiting-your-angular-site-43kg</link>
      <guid>https://dev.to/webdecoy/see-which-bots-and-ai-crawlers-are-visiting-your-angular-site-43kg</guid>
      <description>&lt;p&gt;If a crawler downloads a page without running JavaScript, an Angular service or HTTP interceptor will never see it. The request still reaches the server that delivers your site.&lt;/p&gt;

&lt;p&gt;This walkthrough puts WebDecoy in front of Angular's server-side rendering handler. You can observe requests in monitor mode, inspect the results, and keep serving the application while you decide what needs a response.&lt;/p&gt;

&lt;p&gt;We build WebDecoy. This tutorial was prepared with AI assistance and uses a runnable example from our public SDK repository. It covers WebDecoy request detection, not FCaptcha.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the server that serves your pages
&lt;/h2&gt;

&lt;p&gt;The example uses Angular SSR with an Express server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Crawler or browser → Express → WebDecoy → Angular SSR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same middleware also sees requests to the application's public catalog API. It runs before both the API route and Angular's rendering handler.&lt;/p&gt;

&lt;p&gt;A separate API server cannot observe crawlers that only request HTML from another host. If your Angular app is deployed as static files, put collection at that hosting or CDN layer. Likewise, a CDN cache hit that never reaches Node is outside this middleware's coverage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the example
&lt;/h2&gt;

&lt;p&gt;Use Node.js 26.5, which was used for these checks, or another version supported by your Angular release.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/WebDecoy/node.git
&lt;span class="nb"&gt;cd &lt;/span&gt;node
&lt;span class="c"&gt;# Revision used for this walkthrough:&lt;/span&gt;
git checkout 1a6b0700d45f4437311e359d3ab1ea91927fc0bf
&lt;span class="nb"&gt;cd &lt;/span&gt;examples/angular-monitor/app
npm ci &lt;span class="nt"&gt;--workspaces&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false
&lt;/span&gt;npm run build
npm &lt;span class="nb"&gt;test
&lt;/span&gt;npm run serve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The example installs the published SDK packages independently of the repository's workspaces. Open &lt;code&gt;http://127.0.0.1:4300&lt;/code&gt; and select &lt;strong&gt;Load public catalog&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Use the built Node server for this check. &lt;code&gt;ng serve&lt;/code&gt; is not the production server whose middleware order we are testing.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/WebDecoy/node/tree/1a6b0700d45f4437311e359d3ab1ea91927fc0bf/examples/angular-monitor" rel="noopener noreferrer"&gt;complete source&lt;/a&gt; includes the Angular component, server, lockfile, and integration tests. The example uses Angular 22.2 and WebDecoy's Express and Node packages at 0.18.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  Place detection before Angular's request handler
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;src/server.ts&lt;/code&gt;, WebDecoy runs before the call to &lt;code&gt;angularApp.handle(req)&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;webdecoy&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@webdecoy/express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tripwire&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rateLimit&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@webdecoy/node&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;webdecoy&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WEBDECOY_API_KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;monitor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;honeytoken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;tripwire&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/.env&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/wp-config.php&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;rateLimit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;window&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the middleware block to integrate into your existing Express server, not a complete replacement for &lt;code&gt;server.ts&lt;/code&gt;. Keep your existing routes and rendering handler.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mode: 'monitor'&lt;/code&gt; records a refusal without enforcing it. The tripwire paths let us test a concrete rule without needing a real crawler. The sample does not expose a real environment file or WordPress configuration file at either path.&lt;/p&gt;

&lt;p&gt;The rate limit is intentionally low for the demonstration. It is per process, not a shared limit across a fleet. Adjust or remove it for your workload, or use a shared store if you need one limit across replicas.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;honeytoken: false&lt;/code&gt; turns off automatic HTML trap injection in this example. That keeps the initial setup focused on request observation without adding DOM changes during hydration.&lt;/p&gt;

&lt;p&gt;The server serves real static assets before the middleware and excludes its health endpoint. Other requests pass through detection. The Angular server routes use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;RenderMode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ServerRoute&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@angular/ssr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;serverRoutes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ServerRoute&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;renderMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RenderMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Server&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Angular distinguishes server rendering, prerendering, and client rendering. Its &lt;a href="https://angular.dev/guide/ssr" rel="noopener noreferrer"&gt;hybrid-rendering documentation&lt;/a&gt; explains those choices. Check how your host serves each route before assuming every page request reaches this Node process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspect a decision without blocking the page
&lt;/h2&gt;

&lt;p&gt;With the server running, send these requests from another terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; http://127.0.0.1:4300/api/products
curl &lt;span class="nt"&gt;-i&lt;/span&gt; http://127.0.0.1:4300/.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catalog should return HTTP 200 with two demo items. The tripwire request should produce a terminal record with &lt;code&gt;conclusion: "DENY"&lt;/code&gt;, &lt;code&gt;tripwire: true&lt;/code&gt;, and &lt;code&gt;wouldBlock: true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Its HTTP response stays 404 because the application defines that response and monitor mode lets the request continue. A refusal in the decision log is not the same thing as a blocked request.&lt;/p&gt;

&lt;p&gt;The example logs limited structured decisions. It does not log visitor IPs, full headers, request bodies, or credentials.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connect the dashboard
&lt;/h2&gt;

&lt;p&gt;For cloud reporting, set your own &lt;code&gt;WEBDECOY_API_KEY&lt;/code&gt; in the server environment and restart the server. Create the key in your &lt;a href="https://app.webdecoy.com/" rel="noopener noreferrer"&gt;WebDecoy account&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Keep the key out of Angular configuration, components, and HTTP interceptors. Those can ship to the browser.&lt;/p&gt;

&lt;p&gt;Then send the reserved test request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s1"&gt;'WebDecoy-Test/1.0'&lt;/span&gt; http://127.0.0.1:4300/api/products
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for the labeled &lt;strong&gt;Test&lt;/strong&gt; record in Detections. This checks the reporting pipeline; it does not represent a real AI crawler visit.&lt;/p&gt;

&lt;p&gt;Without a key, the example explicitly logs &lt;code&gt;dashboardConfigured: false&lt;/code&gt; and &lt;code&gt;reportingError: true&lt;/code&gt; for this test. A configured key alone is not proof of delivery either. Check the dashboard record and any reporting errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was verified
&lt;/h2&gt;

&lt;p&gt;The production build and five integration tests passed locally. Those checks cover server-rendered HTML, catalog availability, the tripwire decision, the reserved test trigger, and rate-limit observation while responses continue.&lt;/p&gt;

&lt;p&gt;No API key was used in those tests, so they do not verify cloud delivery or detection accuracy against real AI crawlers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the real traffic after deployment
&lt;/h2&gt;

&lt;p&gt;Start with the requested paths, supporting signals, timing, and crawler classification. Keep a client's claimed user-agent identity separate from any verified identity. A familiar crawler name can be copied by another client.&lt;/p&gt;

&lt;p&gt;Before deploying behind a proxy, configure trusted proxies for your actual network path. The local example trusts no forwarding headers and binds only to loopback. Incorrect proxy settings can attribute many visitors to the same address.&lt;/p&gt;

&lt;p&gt;Finally, detections are not a complete access log. Requests may be allowed locally without being reported, and caching can affect reporting frequency. Keep access logs for total request counts. Monitor mode gives you a way to investigate before changing how the site responds.&lt;/p&gt;

</description>
      <category>angular</category>
      <category>node</category>
      <category>tutorial</category>
      <category>security</category>
    </item>
    <item>
      <title>See which bots and AI crawlers are visiting your React site</title>
      <dc:creator>webdecoy</dc:creator>
      <pubDate>Tue, 29 Sep 2026 18:20:35 +0000</pubDate>
      <link>https://dev.to/webdecoy/see-which-bots-and-ai-crawlers-are-visiting-your-react-site-2c5k</link>
      <guid>https://dev.to/webdecoy/see-which-bots-and-ai-crawlers-are-visiting-your-react-site-2c5k</guid>
      <description>&lt;p&gt;A crawler can request your React site's HTML without ever running React. If you only look at browser analytics, you can miss those requests entirely.&lt;/p&gt;

&lt;p&gt;This tutorial uses WebDecoy to observe requests to a React application served by Express. It starts in monitor mode, so you can inspect detections before choosing whether to block anything. We build WebDecoy; this example uses its Express SDK and cloud dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with where your React site is hosted
&lt;/h2&gt;

&lt;p&gt;React renders your interface. The server or CDN in front of it receives the initial request. That is where this tutorial puts detection.&lt;/p&gt;

&lt;p&gt;The example has a simple request path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Visitor or crawler → Express → WebDecoy → built React site
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your React files live on a static host and your API runs elsewhere, installing this middleware on the API only covers requests to that API. It does not reveal crawlers fetching the separately hosted pages. Use a sensor at the hosting or CDN layer for that traffic.&lt;/p&gt;

&lt;p&gt;For Next.js, use the &lt;a href="https://webdecoy.com/blog/see-bots-ai-crawlers-nextjs/" rel="noopener noreferrer"&gt;Next.js setup guide&lt;/a&gt;, which places detection in its server request hook.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the example
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://webdecoy.com/examples/react-crawler-monitor.zip" rel="noopener noreferrer"&gt;Download the React + Express example&lt;/a&gt; and extract it. With Node.js 22.12 or later installed, run these commands from the extracted directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm ci
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env.local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create an API key in your &lt;a href="https://app.webdecoy.com/" rel="noopener noreferrer"&gt;WebDecoy dashboard&lt;/a&gt; and add it to &lt;code&gt;.env.local&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WEBDECOY_API_KEY=your_api_key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a server credential. Keep it out of React components and do not give it a &lt;code&gt;VITE_&lt;/code&gt; prefix, which is intended for variables exposed to client code.&lt;/p&gt;

&lt;p&gt;Then build and start the application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run build
npm start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://127.0.0.1:3110&lt;/code&gt;. You are visiting Express serving Vite's production output. Running the Vite development server separately would bypass this Express middleware.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put WebDecoy before the page routes
&lt;/h2&gt;

&lt;p&gt;The important part of &lt;code&gt;server.mjs&lt;/code&gt; is the middleware order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;webdecoy&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@webdecoy/express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fileURLToPath&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:path&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fileURLToPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./dist/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;webdecoy&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WEBDECOY_API_KEY&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;monitor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;honeytoken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;skipPaths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/assets/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/assets&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;static&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assets&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/guide&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;index.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Not found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3110&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;127.0.0.1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both page routes pass through WebDecoy before Express serves the HTML. Built assets bypass analysis. Unknown routes still return 404; the middleware does not turn a request for a nonexistent file into a successful page response.&lt;/p&gt;

&lt;p&gt;Monitor mode lets requests continue when WebDecoy would otherwise deny them. &lt;code&gt;honeytoken: false&lt;/code&gt; disables automatic HTML trap injection for this walkthrough, keeping the example focused on observing incoming requests.&lt;/p&gt;

&lt;p&gt;The local server accepts direct connections. Before deploying behind a proxy, configure Express's &lt;code&gt;trust proxy&lt;/code&gt; setting for your actual hosting topology so client IP attribution is correct. Do not blindly trust forwarding headers supplied by clients.&lt;/p&gt;

&lt;p&gt;The downloadable project also logs the request path and decision locally. It avoids logging visitor addresses, full headers, or credentials. Local decisions and cloud reporting are separate: without an API key, those terminal messages do not mean a record reached your dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify reporting with a labeled test request
&lt;/h2&gt;

&lt;p&gt;Send the reserved test user agent to the guide route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s1"&gt;'WebDecoy-Test/1.0'&lt;/span&gt; http://127.0.0.1:3110/guide
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The page should return HTTP 200. In WebDecoy, open Detections and look for the &lt;strong&gt;Test&lt;/strong&gt; record. This is an installation check, not a visit from a real AI crawler. The &lt;a href="https://docs.webdecoy.com/sdk-plugins/express/" rel="noopener noreferrer"&gt;Express integration documentation&lt;/a&gt; describes the reserved test request.&lt;/p&gt;

&lt;p&gt;If the record is missing, confirm that your key is loaded, restart the server after editing &lt;code&gt;.env.local&lt;/code&gt;, and check the server output for errors. Also check the port: a request to a separate Vite server will not run through Express.&lt;/p&gt;

&lt;p&gt;The example was tested locally with React 19.3.0, Vite 8.3.1, Express 5.2.1, and &lt;code&gt;@webdecoy/express&lt;/code&gt; 0.18.0. Its production build passed. A normal page request and the test request returned 200, an unknown route returned 404, and the built JavaScript asset loaded successfully. Those checks used no API key; verify dashboard delivery with your own key using the steps above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the crawler detections
&lt;/h2&gt;

&lt;p&gt;Once the integration is deployed, inspect the real traffic that arrives. Look at the requested paths, crawler classifications, supporting signals, and request timing.&lt;/p&gt;

&lt;p&gt;A name in a user-agent string tells you what the client claims to be. Check any available identity verification before attributing that request to a particular operator. A familiar crawler name alone is not proof.&lt;/p&gt;

&lt;p&gt;Also distinguish requesting a page from rendering it. A crawler requesting the HTML of this client-rendered example has not necessarily executed React or read the content React renders. Server request detection cannot establish that by itself.&lt;/p&gt;

&lt;p&gt;WebDecoy's detection list is not a complete access log. Some requests are allowed locally without being reported, and SDK caching can affect reporting frequency. Requests answered by an upstream CDN without reaching Express are outside this setup's coverage. Use access logs for complete request counts and collection at the CDN for requests served there.&lt;/p&gt;

&lt;p&gt;Leave monitor mode enabled while you learn what is visiting. From there, you can decide which crawlers are useful and which behaviors need a response. The &lt;a href="https://docs.webdecoy.com/monitoring/ai-detections/" rel="noopener noreferrer"&gt;AI detection guide&lt;/a&gt; explains the categories available for investigating that traffic.&lt;/p&gt;

&lt;p&gt;Originally published on &lt;a href="https://webdecoy.com/blog/see-bots-ai-crawlers-react/" rel="noopener noreferrer"&gt;WebDecoy&lt;/a&gt;. This tutorial was prepared with AI assistance. The local checks performed and their limits are described above.&lt;/p&gt;

</description>
      <category>react</category>
      <category>node</category>
      <category>tutorial</category>
      <category>security</category>
    </item>
    <item>
      <title>See which bots and AI crawlers visit your Next.js site</title>
      <dc:creator>webdecoy</dc:creator>
      <pubDate>Tue, 29 Sep 2026 18:17:10 +0000</pubDate>
      <link>https://dev.to/webdecoy/see-which-bots-and-ai-crawlers-visit-your-nextjs-site-4i4k</link>
      <guid>https://dev.to/webdecoy/see-which-bots-and-ai-crawlers-visit-your-nextjs-site-4i4k</guid>
      <description>&lt;p&gt;If you publish a blog, documentation, or a product catalog, it helps to know which automated clients are requesting those pages. Are they identifying themselves as AI crawlers? Which URLs do they visit? Are they fetching content or probing for files that should not be public?&lt;/p&gt;

&lt;p&gt;This guide adds WebDecoy to a Next.js application in monitor mode. Requests continue to your site while you collect detections. You can review the traffic before deciding whether any of it needs blocking.&lt;/p&gt;

&lt;p&gt;We build WebDecoy. The example below uses its Next.js SDK and cloud dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put detection where crawler requests arrive
&lt;/h2&gt;

&lt;p&gt;A browser script can only run when the client executes it. An HTTP crawler can download your HTML without running your React components or analytics script.&lt;/p&gt;

&lt;p&gt;For this walkthrough, detection runs on the server, before the page response. That gives it access to requests from clients that do not execute JavaScript. It does not reveal the internal purpose of every visitor, and it does not prove who operates a client merely because its user agent contains a familiar name.&lt;/p&gt;

&lt;p&gt;You need a Next.js application with a running server and a WebDecoy API key for dashboard reporting. A static export hosted as files needs collection at its hosting or CDN layer instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the complete example
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://webdecoy.com/examples/nextjs-crawler-monitor.zip" rel="noopener noreferrer"&gt;Download the runnable Next.js example&lt;/a&gt;. Extract it, run &lt;code&gt;npm ci&lt;/code&gt;, copy &lt;code&gt;.env.example&lt;/code&gt; to &lt;code&gt;.env.local&lt;/code&gt;, add your API key, and run &lt;code&gt;npm run dev&lt;/code&gt;. It listens on &lt;code&gt;http://127.0.0.1:3107&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The example was built and checked locally with Next.js 16.3.7 and &lt;code&gt;@webdecoy/nextjs&lt;/code&gt; 0.18.0. Normal and reserved test requests both returned HTTP 200 in monitor mode. These local checks used no API key; dashboard delivery is a separate check you will perform below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install the adapter
&lt;/h2&gt;

&lt;p&gt;The companion example pins the adapter to version 0.18.0:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Create an API key in your &lt;a href="https://app.webdecoy.com/" rel="noopener noreferrer"&gt;WebDecoy dashboard&lt;/a&gt; and put it in &lt;code&gt;.env.local&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WEBDECOY_API_KEY=your_api_key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep this variable server-side. Do not prefix it with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; or place it in a React component. When you deploy, add it to your hosting environment as well.&lt;/p&gt;

&lt;p&gt;Without a key, the SDK can run local checks, but those checks will not appear in your cloud dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitor page requests
&lt;/h2&gt;

&lt;p&gt;Next.js 16 uses &lt;a href="https://nextjs.org/docs/app/api-reference/file-conventions/proxy" rel="noopener noreferrer"&gt;&lt;code&gt;proxy.ts&lt;/code&gt;&lt;/a&gt; for this request hook. Place it beside &lt;code&gt;app&lt;/code&gt;, or beside &lt;code&gt;src/app&lt;/code&gt; when your application uses a &lt;code&gt;src&lt;/code&gt; directory:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;withWebDecoy&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WEBDECOY_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;monitor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Assumes one trusted proxy in front of the application.&lt;/span&gt;
  &lt;span class="na"&gt;trustProxy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;The matcher includes your homepage, articles, documentation, and other page routes. It excludes Next.js static assets, image optimization requests, and the favicon. Add exclusions for other assets if your site needs them, but keep the content pages you want to observe.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mode: 'monitor'&lt;/code&gt; lets the request continue even when WebDecoy would otherwise deny it. It does not disable protection implemented elsewhere in your application or hosting provider.&lt;/p&gt;

&lt;p&gt;The proxy setting matters for attribution. This example assumes one trusted proxy that sanitizes forwarding headers. Configure it for your actual hosting chain; copying an arbitrary header into the client IP field can produce misleading results.&lt;/p&gt;

&lt;p&gt;If you already have a proxy for authentication or redirects, integrate detection into that request flow rather than replacing the existing file. For an older Next.js application using the middleware convention, the adapter can be exported from &lt;code&gt;middleware.ts&lt;/code&gt;; do not install both files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check that the installation reports a detection
&lt;/h2&gt;

&lt;p&gt;Start your application, then send the reserved test request to a page covered by the matcher:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s1"&gt;'WebDecoy-Test/1.0'&lt;/span&gt; http://localhost:3000/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use port &lt;code&gt;3107&lt;/code&gt; if you are running the companion example.&lt;/p&gt;

&lt;p&gt;Your page should still load. Open Detections in WebDecoy and look for the record labeled &lt;strong&gt;Test&lt;/strong&gt;. The reserved test user agent checks the reporting pipeline; it is not evidence that a real AI crawler visited your site. The &lt;a href="https://docs.webdecoy.com/sdk-plugins/nextjs/" rel="noopener noreferrer"&gt;installation documentation&lt;/a&gt; describes this test and its dashboard label.&lt;/p&gt;

&lt;p&gt;If nothing appears, check the API key, restart the development server after changing environment variables, and confirm that the request path matches your configuration. A successful page response alone does not establish that reporting worked. Check server errors as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Look at actual crawler activity
&lt;/h2&gt;

&lt;p&gt;After deploying, return to Detections and the AI traffic views as real requests arrive. Start with a few records and inspect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The requested path. An article request and a request for &lt;code&gt;/.env&lt;/code&gt; mean different things.&lt;/li&gt;
&lt;li&gt;The user agent and crawler classification. A recognizable name is useful context, but it is also a string a client can copy.&lt;/li&gt;
&lt;li&gt;The supporting signals and any identity verification shown. Keep a claimed identity separate from one supported by verification.&lt;/li&gt;
&lt;li&gt;The timing and repeated requests. A pattern across several paths can be more useful than a single score.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The SDK's detections are not a complete page-view log. Some requests can be allowed locally without being reported, and SDK caching can affect how often a decision reaches the service. Use access logs when you need a complete request count.&lt;/p&gt;

&lt;p&gt;If an upstream CDN serves a cached response without reaching your Next.js application, this sensor will not see that request. To understand that traffic, collect at the CDN layer or use its access logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decide what to do with what you find
&lt;/h2&gt;

&lt;p&gt;You can leave the application in monitor mode while you learn which traffic is useful. A search crawler fetching public pages may be welcome. Repeated probes for secrets deserve a different response.&lt;/p&gt;

&lt;p&gt;Start with the evidence in the records, then choose a policy for the specific behavior you want to change. You do not need to block every AI crawler to get value from knowing which ones are visiting.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://docs.webdecoy.com/monitoring/ai-detections/" rel="noopener noreferrer"&gt;AI detection guide&lt;/a&gt; explains the available categories. Browser automation and browser-side AI signals require different evidence from a server request; this setup is not a claim that every kind of AI activity is identifiable.&lt;/p&gt;

&lt;p&gt;Originally published on &lt;a href="https://webdecoy.com/blog/see-bots-ai-crawlers-nextjs/" rel="noopener noreferrer"&gt;WebDecoy&lt;/a&gt;. This tutorial was prepared with AI assistance. The local checks performed and their limits are described above.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>security</category>
    </item>
    <item>
      <title>Your Next.js API route is public—even if your UI isn’t.</title>
      <dc:creator>webdecoy</dc:creator>
      <pubDate>Mon, 28 Sep 2026 18:00:32 +0000</pubDate>
      <link>https://dev.to/webdecoy/your-nextjs-api-route-is-public-even-if-your-ui-isnt-31l4</link>
      <guid>https://dev.to/webdecoy/your-nextjs-api-route-is-public-even-if-your-ui-isnt-31l4</guid>
      <description>&lt;p&gt;Hiding a form behind a client-side condition doesn't make its API route private. Unless the route enforces its own checks, another client can call it directly.&lt;/p&gt;

&lt;p&gt;For a public contact form, you may not want to require an account. You still want the server to validate the request before it triggers an email or database write.&lt;/p&gt;

&lt;p&gt;This example uses a Next.js App Router route handler and self-hosted FCaptcha. The browser collects a token. The server verifies it before accepting the submission.&lt;/p&gt;

&lt;p&gt;Disclosure: I build WebDecoy and maintain FCaptcha, the free open-source project used here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it locally
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/WebDecoy/FCaptcha/tree/19e83b5/examples/framework-forms" rel="noopener noreferrer"&gt;Complete example and README&lt;/a&gt;. Use the &lt;code&gt;docs/angular-nextjs-tutorials&lt;/code&gt; branch shown below; these instructions work before it is merged into main.&lt;/p&gt;

&lt;p&gt;The example pins Next.js 16.3.6 and uses FCaptcha 1.42. It was built and tested with Node 26.5.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;--branch&lt;/span&gt; docs/angular-nextjs-tutorials https://github.com/WebDecoy/FCaptcha.git
&lt;span class="nb"&gt;cd &lt;/span&gt;FCaptcha
npm &lt;span class="nt"&gt;--prefix&lt;/span&gt; server-node &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--ignore-scripts&lt;/span&gt; &lt;span class="nt"&gt;--package-lock&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false
cd &lt;/span&gt;examples/framework-forms
npm ci
npm &lt;span class="nt"&gt;--prefix&lt;/span&gt; nextjs ci
npm run next
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;strong&gt;&lt;a href="http://127.0.0.1:3000" rel="noopener noreferrer"&gt;http://127.0.0.1:3000&lt;/a&gt;&lt;/strong&gt;. The launcher starts both Next.js and FCaptcha (port 8788), creates separate temporary signing and verification secrets, and passes server configuration through the environment. Use &lt;code&gt;127.0.0.1&lt;/code&gt; consistently because hostname validation is exact.&lt;/p&gt;

&lt;p&gt;The server-node install command works around an existing upstream lockfile mismatch. The example dependencies have their own lockfiles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate the browser and server responsibilities
&lt;/h2&gt;

&lt;p&gt;The source layout is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nextjs/app/page.js                 client form
nextjs/app/api/contact/route.js    server entry point
shared/browser.js                 widget loading and submission
shared/contact.mjs                validation and verification
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;page.js&lt;/code&gt; is a client component. It imports only the browser helper. A &lt;code&gt;useRef&lt;/code&gt; guard prevents two submissions before React has rendered the pending state; &lt;code&gt;useState&lt;/code&gt; supplies the visible status and disabled button.&lt;/p&gt;

&lt;p&gt;On each attempt, the helper loads the widget, requests a token for &lt;code&gt;action: 'contact'&lt;/code&gt;, then sends &lt;code&gt;{ message, token }&lt;/code&gt; to &lt;code&gt;/api/contact&lt;/code&gt;. It never receives the verification secret.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FCaptcha&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;framework-contact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Verification did not pass. Please try again.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/contact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This excerpt runs after the loader configures FCaptcha. The full helper checks the HTTP response and &lt;code&gt;accepted&lt;/code&gt; field before displaying success.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep verification in the route handler
&lt;/h2&gt;

&lt;p&gt;Here is the complete route entry point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;contactHandler&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../../../../shared/contact.mjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nodejs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;contactHandler&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APP_ORIGIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;captchaOrigin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FCAPTCHA_ORIGIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;verifySecret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FCAPTCHA_VERIFY_SECRET&lt;/span&gt; &lt;span class="p"&gt;})(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;server_configuration_error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shared handler uses Node APIs, so the runtime is explicit. The verification secret is read only on the server. Do not rename it to a &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; variable or pass it to the page as a prop.&lt;/p&gt;

&lt;p&gt;Before contacting FCaptcha, the handler requires the expected Origin, JSON content type, a body no larger than 16 KiB, a nonempty message of at most 2,000 characters and a token. It then performs this server-to-server request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/siteverify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;captchaOrigin&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/x-www-form-urlencoded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;verifySecret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-store&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;verification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Verification unavailable&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;verification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;captcha_rejected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Only now perform the protected action.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A token for another hostname or another action is rejected even if its signature is valid. A successful verification consumes the token, so replay is rejected too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat verification failure as failure
&lt;/h2&gt;

&lt;p&gt;The handler catches network errors and malformed verifier responses and returns 503. A rejected token returns 403; invalid input returns 400. The protected action never runs in those branches.&lt;/p&gt;

&lt;p&gt;Don't turn a timeout into &lt;code&gt;success: true&lt;/code&gt; to keep the form moving. Show an error and let the visitor retry with a new token. The client keeps the message in the textarea and clears its pending state in &lt;code&gt;finally&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You can check that a direct request cannot skip verification:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; http://127.0.0.1:3000/api/contact &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Origin: http://127.0.0.1:3000'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{"message":"hello","token":"forged"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The running example returns &lt;strong&gt;403&lt;/strong&gt; with &lt;code&gt;captcha_rejected&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this example tests
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;npm test&lt;/code&gt; runs 17 integration checks across the Express adapter and the Next.js route handler against the real FCaptcha verification endpoint. They cover valid tokens, replay, forged and expired tokens, hostname/action mismatches, input limits, origin checks and verification outages. Both framework production builds also passed.&lt;/p&gt;

&lt;p&gt;The tests sign fixtures with a temporary test key. They test the integration contract—not the browser's ability to distinguish humans from bots. No human pass-rate claim follows from these tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you deploy
&lt;/h2&gt;

&lt;p&gt;The launcher is deliberately local: it creates fresh, separate signing and verification secrets for every run and binds FCaptcha to loopback. The browser helper also uses a hardcoded loopback URL. Replace those with your own HTTPS origins, public site key and stable server-side secrets for deployment.&lt;/p&gt;

&lt;p&gt;Keep rate limits, request-size limits and authentication/authorization where needed. An Origin check helps with unwanted browser requests, but a script can forge that header. CAPTCHA is another check, not a replacement for authorization or a complete spam defense.&lt;/p&gt;

&lt;p&gt;When you add email or database writes, handle duplicate business actions separately. Verification consumes the token; if the later action fails, the retry needs a new token. Multiple FCaptcha replicas also need shared state for replay protection. Give legitimate visitors a recovery path if verification fails.&lt;/p&gt;

&lt;p&gt;This demo only returns &lt;code&gt;{ accepted: true, demoOnly: true }&lt;/code&gt;. It does not store a message or send an email.&lt;/p&gt;

&lt;p&gt;FCaptcha is free and open source. If you try the example, useful feedback is a reproducible integration issue—including framework version and the failing step—with tokens and secrets removed.&lt;/p&gt;

&lt;p&gt;Framework reference: &lt;a href="https://nextjs.org/docs/app/getting-started/route-handlers" rel="noopener noreferrer"&gt;Next.js Route Handlers&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>security</category>
      <category>tutorial</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Your Angular form has validation. Bots don’t care.</title>
      <dc:creator>webdecoy</dc:creator>
      <pubDate>Mon, 28 Sep 2026 18:00:12 +0000</pubDate>
      <link>https://dev.to/webdecoy/your-angular-form-has-validation-bots-dont-care-1fon</link>
      <guid>https://dev.to/webdecoy/your-angular-form-has-validation-bots-dont-care-1fon</guid>
      <description>&lt;p&gt;A disabled submit button is useful feedback. It does not protect the endpoint behind it.&lt;/p&gt;

&lt;p&gt;Someone can skip your Angular application and send an HTTP request directly. &lt;code&gt;Validators.required&lt;/code&gt; never runs in that request. Neither does your submit handler.&lt;/p&gt;

&lt;p&gt;Let's build a small contact form where the backend decides whether the request is accepted. It uses Angular reactive forms, Express and self-hosted FCaptcha.&lt;/p&gt;

&lt;p&gt;Disclosure: I build WebDecoy and maintain FCaptcha. This is a runnable integration tutorial for our open-source project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the example
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/WebDecoy/FCaptcha/tree/19e83b5/examples/framework-forms" rel="noopener noreferrer"&gt;Complete example and README&lt;/a&gt;. This tutorial's source is on the &lt;code&gt;docs/angular-nextjs-tutorials&lt;/code&gt; branch; it does not depend on a future merge to main.&lt;/p&gt;

&lt;p&gt;Use a current Node version compatible with Angular 22 (tested with Node 26.5). The example pins Angular 22.2 and uses FCaptcha 1.42.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;--branch&lt;/span&gt; docs/angular-nextjs-tutorials https://github.com/WebDecoy/FCaptcha.git
&lt;span class="nb"&gt;cd &lt;/span&gt;FCaptcha
npm &lt;span class="nt"&gt;--prefix&lt;/span&gt; server-node &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--ignore-scripts&lt;/span&gt; &lt;span class="nt"&gt;--package-lock&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false
cd &lt;/span&gt;examples/framework-forms
npm ci
npm &lt;span class="nt"&gt;--prefix&lt;/span&gt; angular ci
npm run angular
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;strong&gt;&lt;a href="http://127.0.0.1:4200" rel="noopener noreferrer"&gt;http://127.0.0.1:4200&lt;/a&gt;&lt;/strong&gt;, not &lt;code&gt;localhost&lt;/code&gt;. The allowed hostname is intentionally exact. The launcher starts Angular, an Express API on port 4201 and FCaptcha on port 8788. Angular proxies &lt;code&gt;/api&lt;/code&gt; to Express so the form submits to its own origin.&lt;/p&gt;

&lt;p&gt;The server dependency command works around an existing server-node lockfile mismatch. The example itself includes dependency lockfiles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep Angular validation for the user experience
&lt;/h2&gt;

&lt;p&gt;The standalone component imports &lt;code&gt;ReactiveFormsModule&lt;/code&gt; and uses a typed form control:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormGroup&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormControl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;nonNullable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;validators&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;Validators&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;required&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Validators&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;busy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The template binds the form and displays a live status message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;[formGroup]=&lt;/span&gt;&lt;span class="s"&gt;"form"&lt;/span&gt; &lt;span class="na"&gt;(ngSubmit)=&lt;/span&gt;&lt;span class="s"&gt;"submit()"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;label&lt;/span&gt; &lt;span class="na"&gt;for=&lt;/span&gt;&lt;span class="s"&gt;"message"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Test message&lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;textarea&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"message"&lt;/span&gt; &lt;span class="na"&gt;formControlName=&lt;/span&gt;&lt;span class="s"&gt;"message"&lt;/span&gt;
            &lt;span class="na"&gt;maxlength=&lt;/span&gt;&lt;span class="s"&gt;"2000"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt; &lt;span class="na"&gt;[disabled]=&lt;/span&gt;&lt;span class="s"&gt;"form.invalid || busy()"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    {{ busy() ? 'Verifying…' : 'Verify and submit' }}
  &lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;role=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt; &lt;span class="na"&gt;aria-live=&lt;/span&gt;&lt;span class="s"&gt;"polite"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ status() }}&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The submit method checks validity, sets &lt;code&gt;busy&lt;/code&gt; before awaiting anything, and clears it in &lt;code&gt;finally&lt;/code&gt;. That prevents accidental double clicks and leaves the form usable after errors. The full component is in &lt;code&gt;angular/src/main.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;These controls improve the browser experience. The server repeats the checks because it cannot trust the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get a token, then submit it to your API
&lt;/h2&gt;

&lt;p&gt;The shared browser helper lazily loads &lt;code&gt;/fcaptcha.js&lt;/code&gt; from the local FCaptcha server and configures that server URL. After loading, the important sequence is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FCaptcha&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;framework-contact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Verification did not pass. Please try again.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/contact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The helper checks the API response too. A successful client result alone never means the message was accepted. Each attempt gets a fresh token; a successfully verified token cannot be reused.&lt;/p&gt;

&lt;h2&gt;
  
  
  Express makes the decision
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;angular/api.mjs&lt;/code&gt; limits the request body to 16 KiB and passes a Web Request to &lt;code&gt;shared/contact.mjs&lt;/code&gt;. The shared handler validates JSON, limits messages to 2,000 characters and requires a token before contacting FCaptcha.&lt;/p&gt;

&lt;p&gt;This is the core server-side verification step, excerpted from that handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/siteverify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;captchaOrigin&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/x-www-form-urlencoded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;verifySecret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-store&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;verification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Verification unavailable&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;verification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;contact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;captcha_rejected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Only now perform the protected action.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;verifySecret&lt;/code&gt; exists only in the server process. The public site key is not a secret. The application checks both the expected hostname and the &lt;code&gt;contact&lt;/code&gt; action, rather than accepting any successful token from any workflow.&lt;/p&gt;

&lt;p&gt;Network errors, non-2xx verification responses and malformed verification JSON produce a 503 response. Rejected tokens produce 403. Neither path sends a message.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this example tests
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;npm test&lt;/code&gt; runs 17 integration checks across the Express adapter and the Next.js route handler against the real FCaptcha verification endpoint. They cover valid tokens, replay, forged and expired tokens, hostname/action mismatches, input limits, origin checks and verification outages. Both framework production builds also passed.&lt;/p&gt;

&lt;p&gt;The tests sign fixtures with a temporary test key. They test the integration contract—not the browser's ability to distinguish humans from bots. No human pass-rate claim follows from these tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you deploy
&lt;/h2&gt;

&lt;p&gt;The launcher is deliberately local: it creates fresh, separate signing and verification secrets for every run and binds FCaptcha to loopback. The browser helper also uses a hardcoded loopback URL. Replace those with your own HTTPS origins, public site key and stable server-side secrets for deployment.&lt;/p&gt;

&lt;p&gt;Keep rate limits, request-size limits and authentication/authorization where needed. An Origin check helps with unwanted browser requests, but a script can forge that header. CAPTCHA is another check, not a replacement for authorization or a complete spam defense.&lt;/p&gt;

&lt;p&gt;When you add email or database writes, handle duplicate business actions separately. Verification consumes the token; if the later action fails, the retry needs a new token. Multiple FCaptcha replicas also need shared state for replay protection. Give legitimate visitors a recovery path if verification fails.&lt;/p&gt;

&lt;p&gt;This demo only returns &lt;code&gt;{ accepted: true, demoOnly: true }&lt;/code&gt;. It does not store a message or send an email.&lt;/p&gt;

&lt;p&gt;FCaptcha is free and open source. If you try the example, useful feedback is a reproducible integration issue—including framework version and the failing step—with tokens and secrets removed.&lt;/p&gt;

&lt;p&gt;Framework reference: &lt;a href="https://angular.dev/guide/forms/reactive-forms" rel="noopener noreferrer"&gt;Angular reactive forms&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>angular</category>
      <category>security</category>
      <category>tutorial</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Who's hitting your WordPress site while you sleep?</title>
      <dc:creator>webdecoy</dc:creator>
      <pubDate>Mon, 28 Sep 2026 17:40:12 +0000</pubDate>
      <link>https://dev.to/webdecoy/whos-hitting-your-wordpress-site-while-you-sleep-42fi</link>
      <guid>https://dev.to/webdecoy/whos-hitting-your-wordpress-site-while-you-sleep-42fi</guid>
      <description>&lt;p&gt;Fake registrations. Repeated login attempts. Strange requests for files you never published. If you run WooCommerce, checkout abuse can add another problem to the list.&lt;/p&gt;

&lt;p&gt;Before enabling another blocking rule, I want to answer a smaller question: &lt;strong&gt;can I see what triggered it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'm Chris, the developer behind WebDecoy. This walkthrough uses our free, open-source WordPress plugin to record a controlled request in monitor mode. Local functionality doesn't require an account or API key; connecting the cloud service is optional.&lt;/p&gt;

&lt;p&gt;The useful part is the workflow: generate one known event, find its record, and separate detection from enforcement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start on one staging site
&lt;/h2&gt;

&lt;p&gt;From the WordPress root, with WP-CLI available:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp plugin &lt;span class="nb"&gt;install &lt;/span&gt;webdecoy &lt;span class="nt"&gt;--activate&lt;/span&gt;
wp webdecoy config &lt;span class="nb"&gt;set &lt;/span&gt;mode monitor
wp webdecoy config get mode
wp webdecoy status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also install &lt;strong&gt;WebDecoy Bot Detection&lt;/strong&gt; through Plugins → Add New. Check the mode before testing. If the plugin is already installed, skip the installation command.&lt;/p&gt;

&lt;p&gt;Monitor mode lets you inspect detections before applying blocking. It still executes code and writes records, so use staging to check compatibility and overhead.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;WEBDECOY_DEFAULT_MODE&lt;/code&gt; forces a mode in &lt;code&gt;wp-config.php&lt;/code&gt;, the CLI won't override it. Read the command result rather than assuming the change worked.&lt;/p&gt;

&lt;p&gt;In the status output, note:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;What to check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;version&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which build you're evaluating&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Confirm &lt;code&gt;monitor&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cloud&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Whether a cloud account is connected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;detections_total&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your starting detection count&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;active_blocks&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your starting active IP-block count&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Give the logger one known event
&lt;/h2&gt;

&lt;p&gt;I ran this on an isolated local WordPress site, bound to loopback at port 18079. There was no real &lt;code&gt;.env&lt;/code&gt; file at the target path. The request exercises a default tripwire path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TEST_BASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'http://localhost:18079'&lt;/span&gt;
curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s1"&gt;'python-requests/2.31'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'HTTP %{http_code}\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TEST_BASE&lt;/span&gt;&lt;span class="s2"&gt;/.env"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use your own isolated test environment, not another person's site or a path containing actual secrets. A CDN, cache, authentication layer, or WAF may stop a request before WordPress sees it.&lt;/p&gt;

&lt;p&gt;Then check again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp webdecoy status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The request returned 301. The detection still happened.
&lt;/h2&gt;

&lt;p&gt;Here's what the September 28 test actually produced:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mode&lt;/td&gt;
&lt;td&gt;monitor&lt;/td&gt;
&lt;td&gt;monitor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total detections&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Active IP blocks&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The HTTP response was &lt;strong&gt;301&lt;/strong&gt;. The saved detection contained the &lt;code&gt;tripwire_rule&lt;/code&gt; flag and &lt;code&gt;/.env&lt;/code&gt; path metadata.&lt;/p&gt;

&lt;p&gt;That is why I don't use HTTP status as my detection test. A redirect can come from another part of the request path. A recorded detection also doesn't prove that the request was blocked.&lt;/p&gt;

&lt;p&gt;This test established that a matching record was created and the active IP-block count didn't increase. It did &lt;strong&gt;not&lt;/strong&gt; measure detection accuracy, false-positive rates, checkout compatibility, or performance.&lt;/p&gt;

&lt;p&gt;The exact environment was WordPress &lt;strong&gt;7.1&lt;/strong&gt;, PHP &lt;strong&gt;8.3.33&lt;/strong&gt;, and WebDecoy &lt;strong&gt;2.10.1&lt;/strong&gt; at &lt;a href="https://github.com/WebDecoy/wordpress-plugin/tree/d386344d65a7487d153125c20705ee594da83a5f" rel="noopener noreferrer"&gt;commit d386344&lt;/a&gt;, with no cloud connection. For this run I mounted that source checkout and activated it with &lt;code&gt;wp plugin activate webdecoy&lt;/code&gt;; the WordPress.org installation command above is the normal distribution route. Record your version because the repository and directory package can update at different times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Match the record, then test real workflows
&lt;/h2&gt;

&lt;p&gt;In WebDecoy's detection log, correlate the timestamp, User-Agent, and detection flags with your request. On a busy site, an increased total alone won't tell you which request caused it.&lt;/p&gt;

&lt;p&gt;Next, stay in monitor mode and exercise the workflows your visitors need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Log in as an administrator and an ordinary user.&lt;/li&gt;
&lt;li&gt;Register, reset a password, and submit a comment where those features are enabled.&lt;/li&gt;
&lt;li&gt;Browse through the same cache/CDN path your visitors use.&lt;/li&gt;
&lt;li&gt;For WooCommerce, test the journey from cart to order confirmation using a test payment environment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Watch for detections that overlap legitimate actions. Understand those before changing enforcement. Shared IPs and reverse proxies also affect how you should interpret an address in a log.&lt;/p&gt;

&lt;p&gt;For agencies, one representative staging site is a useful starting point. It isn't evidence that every client site, theme, checkout, and plugin combination behaves the same way.&lt;/p&gt;

&lt;p&gt;To stop the trial:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp plugin deactivate webdecoy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deactivation is not uninstallation or a promise that stored logs have been erased.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it on a site you control
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://wordpress.org/plugins/webdecoy/" rel="noopener noreferrer"&gt;Install from WordPress.org&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/WebDecoy/wordpress-plugin" rel="noopener noreferrer"&gt;Read the source&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/WebDecoy/wordpress-plugin/blob/d386344d65a7487d153125c20705ee594da83a5f/includes/class-webdecoy-cli.php" rel="noopener noreferrer"&gt;Inspect the WP-CLI implementation used in this test&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's the most persistent unwanted traffic on your WordPress sites: login attempts, fake registrations, comment spam, or checkout abuse? If you try the workflow, I'd be interested in what was hard to interpret in the logs. Please redact IP addresses and customer information before sharing examples.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>security</category>
      <category>opensource</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Block Bots on WordPress Without a CAPTCHA or a WAF</title>
      <dc:creator>webdecoy</dc:creator>
      <pubDate>Sun, 13 Sep 2026 18:32:08 +0000</pubDate>
      <link>https://dev.to/webdecoy/how-to-block-bots-on-wordpress-without-a-captcha-or-a-waf-38l</link>
      <guid>https://dev.to/webdecoy/how-to-block-bots-on-wordpress-without-a-captcha-or-a-waf-38l</guid>
      <description>&lt;p&gt;Most WordPress bot protection works by &lt;em&gt;estimating&lt;/em&gt;. It looks at a User-Agent, a request rate, a header set, and produces a guess. Good scoring gets you a long way — but every guess carries a false-positive tail, and on a checkout page that tail has a price.&lt;/p&gt;

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

&lt;p&gt;This is a practical guide to doing that on WordPress. I'll use the &lt;a href="https://wordpress.org/plugins/webdecoy/" rel="noopener noreferrer"&gt;WebDecoy plugin&lt;/a&gt; for the concrete examples because it's the one I work on and it's free and local, but the technique is the point — you could build most of it yourself in an afternoon.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;p&gt;I wrote a whole piece on &lt;a href="https://webdecoy.com/blog/bot-detection-false-positives-testing-benchmark/" rel="noopener noreferrer"&gt;why bot detection false positives are a business event&lt;/a&gt;, and the short version applies here: you cannot know your false-positive rate until you've watched real traffic, including the weird tail of it — corporate proxies, carrier-grade NAT, accessibility tooling, your own uptime monitors.&lt;/p&gt;

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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



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

&lt;/div&gt;



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




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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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



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

&lt;/div&gt;



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

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

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

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




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

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

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

</description>
      <category>wordpress</category>
      <category>woocommerce</category>
      <category>security</category>
      <category>php</category>
    </item>
    <item>
      <title>Inside a WordPress Bot Detection Engine</title>
      <dc:creator>webdecoy</dc:creator>
      <pubDate>Sun, 13 Sep 2026 16:54:36 +0000</pubDate>
      <link>https://dev.to/webdecoy/inside-a-wordpress-bot-detection-engine-mm3</link>
      <guid>https://dev.to/webdecoy/inside-a-wordpress-bot-detection-engine-mm3</guid>
      <description>&lt;p&gt;The WebDecoy WordPress plugin ships with zero configuration required. But underneath the "install, activate, done" experience is a multi-layer detection engine that scores every request across server-side signals, client-side fingerprints, behavioral analysis, and proof-of-work verification.&lt;/p&gt;

&lt;p&gt;This post walks through how each layer works, how they combine into a single threat score, and why this architecture catches bots that simpler approaches miss. It's a WordPress plugin, but the scoring design applies to any request pipeline.&lt;/p&gt;

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Incoming Request
  │
  ├─ Is this IP blocked? → Yes → Block page
  │
  ├─ Is this a known good bot? → Verify via reverse DNS → Allow
  │
  ├─ Server-side analysis
  │    ├─ User-Agent patterns
  │    ├─ HTTP header consistency
  │    ├─ MITRE ATT&amp;amp;CK path matching
  │    └─ Rate limit check
  │
  ├─ Client-side signals (on form submission)
  │    ├─ WebDriver / headless detection
  │    ├─ Automation framework markers
  │    ├─ Canvas / WebGL fingerprint
  │    └─ Behavioral scoring
  │
  ├─ Proof-of-Work verification (on form submission)
  │    └─ SHA-256 challenge validation
  │
  └─ Score aggregation → Allow / Challenge / Block
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

&lt;p&gt;&lt;strong&gt;The scoring is additive.&lt;/strong&gt; A request doesn't need to fail one dramatic test — it accumulates evidence across multiple signals. A slightly suspicious user agent (+25) combined with missing cookies (+15) and an unusual request path (+20) adds up to 60, enough to trigger a challenge. No single signal is conclusive, but the combination tells a clear story.&lt;/p&gt;

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

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

&lt;/div&gt;



&lt;p&gt;A real Chrome browser hitting a normal page scores near zero. A Python script with a spoofed user agent, no cookies, and missing standard headers quickly crosses the blocking threshold.&lt;/p&gt;

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

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

&lt;p&gt;The plugin checks the User-Agent against known bot patterns (curl, wget, python-requests, Go-http-client, scrapy, and dozens more) and evaluates header consistency. Real browsers send a predictable set of headers — Accept, Accept-Language, Accept-Encoding, Connection — in a consistent order. Automated tools frequently omit headers or send them in unusual combinations.&lt;/p&gt;

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;p&gt;The server-side layer catches unsophisticated bots. The client-side layer targets headless browsers, automation frameworks, and tools that spoof headers but can't perfectly replicate a real browser environment.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Mouse velocity variance&lt;/strong&gt; is particularly effective. Humans move with variable speed — accelerating, decelerating, overshooting, correcting. Bots that simulate movement typically use linear interpolation or simple easing functions, producing unnaturally smooth velocity profiles.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Micro-tremor score&lt;/strong&gt; looks for the tiny involuntary oscillations present in all human hand movement. These have characteristic frequency patterns that are difficult to simulate; their absence suggests input generated by code.&lt;/p&gt;

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

&lt;p&gt;Invisible form fields real users never see. If a field receives a value, the submission came from a bot that filled every input on the page.&lt;/p&gt;

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

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

&lt;/div&gt;



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

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

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Server generates:
  prefix:      "a7f3c8e91b04d265"   (16 hex chars from 8 random bytes)
  difficulty:  4                     (requires 4 leading zero hex chars)
  expires:     current_time + 5 minutes
  signature:   HMAC-SHA256(challenge_data, wordpress_auth_key)

Client computes:
  nonce = 0:  SHA-256("a7f3c8e91b04d265" + "0") = "7f2a..."   (fail)
  nonce = 1:  SHA-256("a7f3c8e91b04d265" + "1") = "b391..."   (fail)
  ...
  nonce = N:  SHA-256("a7f3c8e91b04d265" + "N") = "0000a..."  (pass)

Client submits:  { challengeId, nonce, signature }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;Why it stops bots:&lt;/strong&gt; a single challenge is trivial, but the economics change at scale. A bot submitting 10,000 spam comments needs 10,000 challenges — about 655 million hash operations. Achievable, but it costs real compute.&lt;/p&gt;

&lt;p&gt;Difficulty also scales with threat signals. An IP already flagged by server-side analysis gets harder challenges. Default difficulty 4 is intentionally low for normal users; suspicious traffic might face difficulty 6, roughly 16 million hashes per challenge.&lt;/p&gt;

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

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

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

&lt;p&gt;Signing rather than storing means the server never writes issued challenges to the database. That keeps the table clean and removes a DoS vector where an attacker floods the challenge endpoint to fill storage.&lt;/p&gt;

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

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

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

&lt;/div&gt;



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

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

&lt;p&gt;&lt;strong&gt;A fake Googlebot scores +80 — an instant block&lt;/strong&gt; — because spoofing a search crawler is a strong signal of intent. Results cache in WordPress transients with a 1-hour TTL to avoid repeated lookups.&lt;/p&gt;

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

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

&lt;p&gt;Attackers test stolen card numbers against real checkout flows. Every failed transaction generates processor fees, and a high decline rate can get your payment processing suspended.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checkout velocity limiting&lt;/strong&gt; — configurable max checkout attempts per IP per window (default: 5 per hour). Legitimate shoppers rarely attempt checkout more than once or twice. An IP submitting 20 attempts in an hour is testing cards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Card testing pattern detection&lt;/strong&gt; — multiple different card numbers from one IP, rapid sequential attempts, and headless signatures on the checkout page all trigger detection, blocking before further transactions reach the processor.&lt;/p&gt;

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

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

&lt;p&gt;&lt;strong&gt;No external dependencies for core protection.&lt;/strong&gt; The entire detection engine runs on your server. No API calls during request processing, no third-party JavaScript on the frontend. Protection works during API outages, on airgapped installs, and at any traffic volume without per-request costs. Even the admin dashboard's charting library is bundled into the plugin rather than pulled from a CDN, so the plugin makes zero external connections unless you explicitly add a Cloud API key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Additive scoring over binary decisions.&lt;/strong&gt; Every signal adds to a score rather than making a pass/fail call. This dramatically reduces false positives: a single suspicious signal might be coincidence, five together are a pattern. No single check that misfires can block a legitimate user on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Daily rotating honeypot names.&lt;/strong&gt; Static names get learned and skip-listed. Seeded rotation changes them daily while staying deterministic, so the server can verify which fields are honeypots without storing state.&lt;/p&gt;

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

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



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

&lt;/div&gt;



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

&lt;p&gt;Defaults (sensitivity medium, block threshold 75, rate limit 60/min, PoW difficulty 4) work for most sites. Requires WordPress 6.1+ and PHP 7.4+. GPL-licensed.&lt;/p&gt;




&lt;p&gt;If you're building request scoring in any stack, the transferable idea here is the additive model: resist the urge to make any single signal decisive, and let evidence accumulate instead. It's the difference between a detector that's occasionally spectacularly wrong and one that's boringly right.&lt;/p&gt;

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




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

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

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

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>security</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
