Website contact forms are a funny thing — they’re essential for business, yet often the first point of attack for spam bots. Recently, one of my client projects started receiving a steady stream of AI-generated “business inquiries” written in perfect Japanese. They looked human at first glance, but the telltale signs were there: generic greetings, embedded tracking links, and copy-and-paste marketing text.
At peak, the site was receiving 20-30 spam submissions daily — roughly 95% of all form traffic.
The challenge was clear: Eliminate automated spam without adding friction for real users — or breaking accessibility.
Step 1: Understanding the Problem
Most spam filters rely on CAPTCHAs that frustrate users, especially those using assistive technology. I needed something that would:
- Run invisibly for genuine users,
- Stop automated form submissions cold, and
- Stay fully compliant with screen readers and keyboard navigation.
After analyzing server logs and form submission patterns, I confirmed that the attackers were submitting via automated scripts — not manually filling out fields. Response times were suspiciously fast (under 2 seconds from page load to submission), and they were bypassing client-side validation entirely by posting directly to the API endpoint.
Step 2: Building a Stealth Honeypot
The first line of defense was a honeypot field — a hidden input that normal users never see, but bots eagerly fill in.
The trick was to disguise it so that smarter bots wouldn’t detect a class name like hidden or honeypot.
Here’s the approach I implemented in Astro:
<!-- ContactForm.astro -->
<form id="contactForm" method="POST" action="/api/contact">
<input type="text" name="name" required placeholder="Your Name" />
<input type="email" name="email" required placeholder="Your Email" />
<textarea name="message" required placeholder="Your Message"></textarea>
<!-- Stealth honeypot -->
<div style="position:absolute;opacity:0;width:1px;height:1px;overflow:hidden;">
<label for="field_reference">Leave this blank</label>
<input
type="text"
id="field_reference"
name="field_reference"
tabindex="-1"
autocomplete="off"
aria-hidden="true"
/>
</div>
<button type="submit">Send</button>
</form>
A few implementation notes:
- I used CSS positioning (
position: absolute+opacity: 0) rather thandisplay: nonebecause some bots now detect and skip display:none elements. tabindex="-1"removes it from keyboard navigation entirely.aria-hidden="true"ensures screen readers skip it completely.- The label “Leave this blank” is technically visible to screen readers, but assistive tech users can tab right past it without interaction.
And on the server side:
// src/pages/api/contact.ts
const trap = data.get("field_reference");
if (trap && trap.toString().trim() !== "") {
return new Response(JSON.stringify({ success: false, error: "Bot detected" }), {
status: 400,
});
}
This simple addition blocked roughly 85% of bot submissions instantly — all without impacting accessibility.
Step 3: Layering Cloudflare Turnstile for Advanced Protection
To catch more sophisticated AI-driven scripts, I added Cloudflare Turnstile, a privacy-friendly alternative to reCAPTCHA.
I chose Turnstile over reCAPTCHA for a few reasons:
- Privacy-first: No Google tracking or data collection
- Better performance: Lighter client-side footprint (~30KB vs reCAPTCHA’s ~100KB+)
- Truly invisible: In invisible mode, there’s no badge flash during page load, and verification happens seamlessly in the background
It runs invisibly and generates a verification token only after confirming the user’s browser behavior is human.
Here’s how I integrated it into the form:
<div
class="cf-turnstile"
data-sitekey={siteKey}
data-theme="light"
data-size="invisible"
data-callback="onTurnstileSuccess"
></div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script>
function onTurnstileSuccess() {
document.getElementById("contactForm").submit();
}
document.addEventListener("DOMContentLoaded", () => {
const form = document.getElementById("contactForm");
form.addEventListener("submit", (e) => {
e.preventDefault();
turnstile.execute(); // Trigger verification first
});
});
</script>
Then, in the API route:
const token = data.get("cf-turnstile-response");
const verifyRes = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: `secret=${secret}&response=${token}`,
});
const verifyData = await verifyRes.json();
if (!verifyData.success) {
return new Response(JSON.stringify({ success: false, error: "Verification failed" }), {
status: 403,
});
}
For development, I configured automatic switching between Cloudflare test keys (which always pass) and real keys for production — ensuring smooth local testing without domain restrictions:
const isProduction = import.meta.env.PROD;
const siteKey = isProduction
? import.meta.env.TURNSTILE_SITE_KEY
: '1x00000000000000000000AA'; // Cloudflare test key
This meant zero friction during local development — no need to configure domains or bypass checks during testing.
Turnstile verification adds roughly 200-300ms of latency, which is imperceptible to users but catches the remaining 15% of spam that slipped past the honeypot.
Step 4: Zod Validation for Data Integrity
While Turnstile stopped bots, I also wanted to make sure real submissions were valid — and I wanted defense in depth.
Using Zod, I created a single shared schema to validate form input both client-side and server-side:
import { z } from "astro/zod";
export const contactSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
message: z.string().min(10),
terms: z.literal("on"),
});
This keeps the form resilient — if a user somehow bypasses the frontend validation (or a bot tries to post malformed data directly to the API), invalid data still won’t reach the email service or CRM. It’s a simple but effective layer that ensures only clean, structured data makes it through.
Architecture Overview
Here’s how the layers work together:
Submission Flow:
- Client-side: Zod validation
- Client-side: Turnstile verification triggered
- Server-side: Honeypot check
- Server-side: Turnstile token verification with Cloudflare
- Server-side: Zod validation (defense in depth)
- Contact created in Brevo via API
Each layer is lightweight, and failure at any stage blocks the submission. Most importantly, legitimate users only experience step 1 and 2 — the rest happens transparently.
Edge Cases & Trade-offs
A few considerations I made during implementation:
- JavaScript dependency: Turnstile requires JavaScript, so I added a
<noscript>fallback notice for the <1% of users with JS disabled. In that case, I suggest they email directly. - Rate limiting: I added server-side rate limiting (not shown above) at the API level to prevent brute-force attempts — 5 submissions per IP per hour.
- Regional blocking: Turnstile gracefully degrades if Cloudflare is blocked in certain regions (rare, but it happens). The honeypot still catches most bots in those cases.
- Time-based analysis: I initially considered rejecting submissions under 3 seconds (likely bots), but found it unnecessary with the current setup and didn’t want false positives from autofill users.
The Result
Within a few hours of deployment, the results were immediate and measurable:
After 3 months in production:
- Spam submissions blocked: 100% (0 successful spam submissions reached the client)
- False positives: 0 out of ~150 legitimate submissions
- Average latency added: <300ms (imperceptible)
- Maintenance required: None
Perhaps most importantly, the solution is invisible to real visitors. There’s no CAPTCHA puzzle, no “click all the traffic lights” — just a seamless, secure contact experience.
Lessons Learned
This project reinforced several key engineering principles:
- Defense in depth: Multiple layers beat a single “perfect” solution. Each layer catches what the previous one missed.
- Invisible security is good UX: The best anti-spam doesn’t announce itself to users.
- Accessibility isn’t optional: Solutions that work for everyone are better engineered, period.
- Developer experience matters: Local dev shouldn’t require production credentials or complicated workarounds.
Security and usability don’t have to be in conflict — when done right, they strengthen each other.
Technologies Used
- Astro 4.x (SSR enabled) for frontend and API routes
- Cloudflare Turnstile for invisible bot protection
- Zod for type-safe input validation
- Brevo API for programmatically creating CRM and Email Marketing contacts
If you’re dealing with form spam, this stack is production-ready and takes about half a day to implement properly. The combination of honeypot + Turnstile + validation creates defense in depth without UX friction — and it’s been running maintenance-free since deployment.