Guide
How to stop contact form spam without a CAPTCHA
Most contact form spam is not clever. It is scripts that find a <form>, fill every field, and post as fast as they can. You can stop the bulk of it without making real visitors click on traffic lights, by stacking a few cheap checks on the server. None of them is perfect alone; together they leave very little for a human to delete.
The examples below are for a Cloudflare Worker that receives the form, but the ideas carry over to any backend.
1. A honeypot field with a believable name
Add a field that people never see and bots happily fill. Hide it with CSS, not type="hidden", because many bots skip hidden inputs. Give it a plausible name such as website rather than honeypot, and keep it out of the tab order and autofill.
<div class="hp" aria-hidden="true">
<label for="website">Website</label>
<input id="website" name="website" type="text" tabindex="-1" autocomplete="off">
</div>
<style>.hp { position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden; }</style>On the server, if website has a value, return the same success response a real visitor gets and drop the message. Telling the bot it failed only teaches it to try again.
2. A time trap
People take more than a couple of seconds to type a message. Scripts don’t. Stamp the time when the page loads and reject posts that arrive too quickly:
<input type="hidden" name="_t" id="_t">
<script>document.getElementById("_t").value = Date.now();</script>Be honest about what this is: a static page can’t sign the timestamp, so a determined bot can fake it. It still catches the lazy majority. Treat a missing value as suspicious rather than fatal, since a visitor with JavaScript off will send an empty field, and don’t reject very old stamps, because people leave tabs open for hours.
3. Cheap content checks
A few rules catch the rest of the obvious junk. Keep them loose so real messages always get through:
- Validate the email format and cap every field’s length.
- Count links in the message. Two or more links from a first-time sender is a strong spam signal for most small sites.
- Reject messages where the name field contains a URL.
- Strip newlines from anything you put in an email header, such as the subject or reply-to.
4. Rate limiting per IP
Workers have a built-in rate limiting binding, so you don’t need a database to slow down a flood. Add it to your Wrangler config:
[[ratelimits]]
name = "FORM_LIMITER"
namespace_id = "1001"
simple = { limit = 5, period = 60 }The period can be 10 or 60 seconds. Counts are kept per Cloudflare location and are meant for abuse protection, not exact accounting, which is fine for a contact form.
The Worker, with all four checks
const ok = (env) => Response.redirect(env.THANKS_URL, 303);
export default {
async fetch(request, env) {
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
const ip = request.headers.get("CF-Connecting-IP") || "unknown";
const { success } = await env.FORM_LIMITER.limit({ key: ip });
if (!success) return new Response("Too many requests", { status: 429 });
const form = await request.formData();
const get = (k, max) => String(form.get(k) || "").trim().slice(0, max);
// 1. Honeypot: pretend it worked.
if (get("website", 200)) return ok(env);
// 2. Time trap: under 3 seconds is a bot.
const t = Number(form.get("_t"));
if (t && Date.now() - t < 3000) return ok(env);
// 3. Content checks.
const name = get("name", 100);
const email = get("email", 200);
const message = get("message", 5000);
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || !message) {
return new Response("Please check your email and message.", { status: 400 });
}
const links = (message.match(/https?:\/\//gi) || []).length;
if (links >= 2 || /https?:\/\//i.test(name)) return ok(env);
await env.DB.prepare(
"INSERT INTO messages (name, email, message, created_at) VALUES (?, ?, ?, ?)"
).bind(name, email, message, new Date().toISOString()).run();
return ok(env);
},
};Silently accepting spam (instead of returning an error) matters. If the bot sees a 400, its operator tweaks it. If it sees the same redirect as everyone else, it moves on believing it won.
5. Turnstile as the backstop, not the front door
If a targeted bot gets through all of that, add Cloudflare Turnstile. It’s free, and in managed or invisible mode most visitors never see a challenge, so it doesn’t cost you the conversions a classic CAPTCHA does. The part people skip is server-side verification: the widget alone does nothing unless your Worker checks the token with Siteverify. The Turnstile contact form tutorial has the full code.
What to expect
On a typical small site, the honeypot and the time trap do most of the work, and the link rule catches much of what’s left. Rate limiting is there for the bad day when someone points a script at you. Keep a look at what ends up stored for the first couple of weeks and loosen any rule that blocks a real person.
Skip the wiring
Formharbor is a $19 one-time kit with these checks already built in: a honeypot, Turnstile verification, D1 storage, email notifications, plus a referral waitlist and R2 file uploads, all on Cloudflare’s free tier.