Every contact form on the internet eventually gets spammed. The standard answer is reCAPTCHA — a Google product that asks real users to identify traffic lights while doing nothing to stop determined attackers. There's a better way that requires no external dependencies and doesn't punish legitimate users.
This is the exact setup running on this site's /contact endpoint: two independent layers that each return { ok: true } when triggered, giving bots zero signal that they've been blocked.
Why not CAPTCHA
reCAPTCHA has three problems:
- It loads third-party JavaScript from
google.com, which breaks CSP, adds latency, and sends your visitors' behavior data to Google. - It creates friction for real users. Every time someone has to solve a puzzle to contact you, some percentage of them closes the tab.
- It doesn't stop sophisticated spam. CAPTCHA-solving farms cost about $2 per 1,000 solves. Spammers use them.
The goal isn't an impenetrable wall — it's stopping automated, low-effort spam (which is 95% of what you'll see) without touching the experience for legitimate visitors.
Layer 1: Honeypot field
A honeypot is a form field that's invisible to real users but filled in by bots that auto-complete forms. The concept is simple: if this hidden field has a value, the submission is from a bot.
html<input
class="contact-honeypot"
type="text"
name="website"
tabindex="-1"
autocomplete="off"
aria-hidden="true"
/>
The CSS keeps it completely off-screen:
css.contact-honeypot {
position: absolute;
left: -9999px;
opacity: 0;
pointer-events: none;
height: 0;
width: 0;
overflow: hidden;
}
A few details matter here. The field is named website — something a bot would plausibly want to fill. tabindex="-1" removes it from keyboard navigation so screen readers and keyboard users can't accidentally reach it. aria-hidden="true" hides it from assistive technology.
The server check is a single condition, evaluated before any database or email operations:
jsconst { name, email, message, type, website } = await request.json();
// Honeypot — bots fill it, humans don't. Return ok silently.
if (website) {
return Response.json({ ok: true });
}
Returning { ok: true } is intentional. If you return an error, scrapers learn the field exists and adapt. Silence is more effective.
Layer 2: IP rate limiting via D1
The honeypot stops dumb bots. Rate limiting stops anything that gets past it. One submission per IP per hour is enough to make bulk spam impractical while never affecting real users.
The implementation reuses the reaction_ratelimits table that already exists in the site's D1 database — the same table that rate-limits emoji reactions on posts. No schema changes needed.
sqlCREATE TABLE IF NOT EXISTS reaction_ratelimits (
key TEXT PRIMARY KEY, -- "contact:{ip}" for form, "ip:slug:emoji" for reactions
expires_at INTEGER NOT NULL -- Unix timestamp
);
The check-and-set logic runs after the honeypot:
jsconst ip = request.headers.get("CF-Connecting-IP") ?? "unknown";
const rlKey = `contact:${ip}`;
const now = Math.floor(Date.now() / 1000);
// Check if this IP is rate limited
const existing = await env.DB
.prepare("SELECT expires_at FROM reaction_ratelimits WHERE key = ?")
.bind(rlKey)
.first();
if (existing && existing.expires_at > now) {
return Response.json({ ok: true }); // Silent block
}
// Set or update the rate limit window (1 hour)
await env.DB
.prepare(`
INSERT INTO reaction_ratelimits (key, expires_at)
VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET expires_at = excluded.expires_at
`)
.bind(rlKey, now + 3600)
.run();
CF-Connecting-IP is a header Cloudflare adds to every request with the real client IP — no proxy headers to spoof, no X-Forwarded-For parsing needed.
The rate limit write happens before sending email, not after. If D1 is unavailable, the try/catch around initDb() means the submission goes through — fail open is the right default here. You'd rather receive a duplicate than silently drop a legitimate message.
Why both layers
Each layer fails differently:
- The honeypot fails against bots that are smart enough to detect hidden fields (rare in automated spam, common in targeted attacks).
- Rate limiting fails against distributed attacks from many IPs.
Together they cover the full spectrum of what a contact form on a personal site actually faces. Neither layer requires a network call to an external service, neither adds latency to legitimate submissions, and neither reveals to an attacker what mechanism blocked them.
What this doesn't cover
This won't stop a human manually spamming your form from a VPN. For that, you'd need something like Cloudflare Turnstile (the non-invasive CAPTCHA alternative that only challenges suspicious traffic). But for the automated bulk spam that hits every public form, two silent server-side checks are enough.
The full implementation — including the contact form handler, honeypot CSS, and validation — lives in src/worker.js if you want to see it in context.