There’s no pure win-win situation in fighting the spam – bots are getting more clever, while Captchas are getting dumber – less effective & more aggressive, resulting in false-positives, frustration for legitimate users and opportunity loss for the site owners.
Since most automations and scripts (those not riding a headless browser) simply parse the DOM and rush to POST to the action URL, somewhat attempting to mimic human behavior bz using patterns with algorithmic randomness, they rarely “interact” with the content and UI elements in a human way, nor do they have time for that – after all, their job is efficiency.
In one of my recent swings from desperate to pissed off, to really pissed off with bots and mitigation (<30 minutes upon publishing an app 20k bot requests drowned my logs completely), I came up with this Behavioral Validation mechanic – name may be fancy, but the approach is practical, I promise.
THE Concept
Of course, to even attempt to be effective, we need to apply a multi-level approach:
- Give the bots a bait – the page will appear normal – all elements and states will be there, impersonating the real-deal but evolving with an actual interaction in a non-immediately obvious way.
- The hook – the contact form is comprised of accordions, revealing the fields and content as the human would populate it.
- By initiating a state change (opening the accordion) you create a pattern that simple DOM-traversing bots won’t even see.
- Throw in some button for good measure – nicer UX than a dropdown.
- And a secret sauce – the element IDs/classes change as you progress through the page flow and interact with the elements.
Layer 1: The Bait: Fake States & Elements
Default State: The “Submit” button’s name attribute is assigned to another button, pointing to a “trap” endpoint. The “real” (Slim Shady) button is inside a closed/collapsed accordion, making no difference to the actual user.
Thus eliminating the “cheap” bots.
Layer 2: The Switch: “The Interaction Token”
Don’t just track if it was opened on the server; use the interaction to unlock the form.
The Trigger: When a user (or the other guy) clicks to expand the accordion section, a function:
- Generates a unique “Interaction Token” (UUID or a timestamp).
- Injects this token into a hidden input field.
- Updates the form’s action & enables the actual submit button.
Layer 3: The Hook: “The Worker Check”
When the Worker receives the POST request, checks for that token – if the token is missing or was generated less than 2 seconds before submission, ta-da it’s a bot.
Why this is better than Turnstile/reCAPTCHA
- No False Positives: Unlike Cloudflare’s risk scoring (which might hate a user’s VPN or browser headers), this only cares about a physical action. If a human opens the accordion and types, they pass. 100% success rate for real people.
- Low Resource Cost: It requires zero external API calls to Google or Cloudflare’s challenge servers, keeping your Worker’s execution time extremely low.
Caveat of Humanized Bots
Advanced “headless” bots can and do click elements. To tackle them, we combine the accordion idea with a Dynamic Field Name.
Use the Worker to inject a random name for the message field into the HTML (e.g., <textarea name="your_company">). Store that random string in a Cookie or KV. If the bot submits name="message", you know it’s using a cached version of your form or a generic scraper.
To implement a dynamic honeypot alongside the accordion, we’ll use Cloudflare’s HTMLRewriter. This allows modifying the HTML on the fly as it leaves the edge, ensuring that every time a user (or bot) requests the form, the “required” field names are different.
At least prevents hard-coded scripts from spamming you.
Architecture & Implementation
- The Worker generates a random session ID and stores it in a cookie.
HTMLRewriterreplaces the genericname="message"in your HTML with a unique hash based on that session ID.- The Submission must contain the hashed field name, or the Worker rejects it.
The Cloudflare Worker Example
Not a production worker, just a POC prototype code
const SECRET_SALT = "your-very-secret-salt";
// Helper to generate a unique field name for this session
async function getDynamicName(sessionId) {
const msg = new TextEncoder().encode(sessionId + SECRET_SALT);
const hashBuffer = await crypto.subtle.digest("SHA-1", msg);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return (
"field_" +
hashArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
.substring(0, 10)
);
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
// 1. GET Request: Serve the form with a dynamic field name
if (request.method === "GET") {
const response = await fetch("https://your-origin.com/form.html"); // or your KV/Static site
const sessionId = crypto.randomUUID();
const dynamicName = await getDynamicName(sessionId);
const modifiedResponse = new HTMLRewriter()
.on('textarea[name="message"]', {
element(el) {
el.setAttribute("name", dynamicName);
el.setAttribute("placeholder", "Your message...");
},
})
.transform(response);
// Set a cookie so we know which dynamic name to expect on POST
modifiedResponse.headers.append(
"Set-Cookie",
`form_session=${sessionId}; HttpOnly; Secure; SameSite=Strict`,
);
return modifiedResponse;
}
// 2. POST Request: Validate the submission
if (request.method === "POST") {
const formData = await request.formData();
const cookies = request.headers.get("Cookie") || "";
const sessionId = cookies.match(/form_session=([^;]+)/)?.[1];
if (!sessionId) return new Response("Missing Session", { status: 403 });
const expectedName = await getDynamicName(sessionId);
const userMessage = formData.get(expectedName);
const accordionInteract = formData.get("ui_interact"); // From your accordion logic
// VALIDATION LOGIC
if (!userMessage) {
return new Response("Bot detected: Used wrong field name.", {
status: 403,
});
}
if (!accordionInteract || accordionInteract === "false") {
return new Response("Bot detected: Accordion never opened.", {
status: 403,
});
}
// Success: Process email via Worker
return new Response("Message sent successfully!");
}
},
};
2. Frontend Integration (The Accordion)
Your HTML can remain simple. The Worker will do the heavy lifting of swapping the names.
<form action="/submit" method="POST">
<details id="form-accordion">
<summary>Contact Support</summary>
<div class="content">
<input type="hidden" name="ui_interact" id="ui_interact" value="false" />
<textarea name="message" required></textarea>
<button type="submit">Send Message</button>
</div>
</details>
</form>
<script>
document.getElementById("form-accordion").addEventListener("toggle", (e) => {
if (e.target.open) {
document.getElementById("ui_interact").value = "true";
}
});
</script>3. Worker Config (Important bit in there)
"assets": {
"directory": "./public", // `index.html` file location to be served at `/`
"binding": "ASSETS",
"run_worker_first": true, // or ["/assets/protected/*", "/documents/*"]
},
/* We need the worker to run first so it can use HTMLRewriter on index.html
If we don't set this, Cloudflare serves static assets directly
without invoking our Worker. */Default Worker behavior is that assets are matched/served first.
With run_worker_first, the Worker runs first – on all routes or the ones you choose – and intercepts requests before the static-asset handler.
This is useful in many scenarios, e.g. it allows to check cookies, serve transformed images, etc.
Also works with negative patterns, e.g., run Worker first for /auth/* except !/auth/forgot-pass (["/auth/*", "!/auth/forgot-pass"]).
Maintenance Tip: Auto-Cleanup
Since R2 can over time gather junk, I recommend setting an Object Lifecycle Policy on your bucket:
R2 > your-bucket > Settings > Object Lifecycle – Set a rule to delete objects after 30 days.
This way, the quarantine cleans itself automatically.
Accessibility Notes
When using the honeypot method, ensure you use aria-hidden="true" and tabindex="-1" on your honeypot fields.
This prevents screen readers from announcing them to legitimate users of accessibility features, who might otherwise fill them out and get flagged as spam.
Make the accordion keyboard-accessible (using <details> and <summary> helps here) so users with screen readers can also trigger the “interaction”.
Why I like this
(apart from it being my idea)
By combining these two methods, you create a “Moving Target” defense:
- The Accordion prevents simple scrapers and “blind” POST bots that don’t trigger browser events.
- The Dynamic Name prevents bots that “learn” your form structure. Even if a human-supervised bot figures out your form once, the field name changes for the next session.
- The Cookie/Session link ensures the submission is tied to a legitimate page-view request, preventing direct-to-endpoint attacks.
- No human was harmed
Image courtesy of Nano Banana 2
// My girlfriend used to call me that


Leave a Reply