Building This Site Part 3
The bots found the contact form
This post is part of a series on my process and progress building out this site from a plain portfolio page into something that can actually hold writing, projects, and whatever else I decide belongs here.
In Part 2 I wrote about paths, caching, permalinks, and the other small betrayals involved in making a website behave after it leaves localhost.
Since then, the site moved to Cloudflare Pages, got its own domain, and gained a working contact form.
Naturally, the bots found it almost immediately.
I suppose this means the website is officially real now.
The contact form already worked
The frustrating part was that the spam did not reveal a broken contact form.
It revealed a working one.
A visitor could fill out the form on the site, the submission would pass through a Pages Function, a separate Cloudflare Worker would process it, and Cloudflare Email Routing would deliver it to my inbox.
The basic route looked like this:
Contact form
↓
Cloudflare Pages Function
↓
mcgowenlab-contact Worker
↓
Cloudflare Email Routing
↓
My inbox
That setup did exactly what I built it to do.
Unfortunately, a bot is still a visitor in the most technical and least useful sense of the word.
The form accepted the fields, the Worker processed them, and the message arrived. The system had no reason to care whether the sender was a real person, a script, or a very determined toaster.
So the next goal became:
Keep the form easy for actual people while making automated submissions expensive, limited, or pointless.
That called for several small layers rather than one dramatic solution.
A contact form is a tiny public API
I had been thinking of the contact form as part of the page.
It is, visually.
Technically, though, it is a public endpoint that accepts data from anyone on the internet and causes something else to happen. In this case, it sends me an email.
That is an API.
A very small API, certainly. It does not have version numbers, documentation, or an enterprise sales team. But it still accepts input from strangers, and that means the browser cannot be trusted to enforce the rules.
Anything checked only in the HTML can be skipped by sending a request directly to the endpoint.
That changed where the real spam protection needed to live.
The form can help identify legitimate visitors, but the Worker has to make the final decision.
Layer one: the honeypot
The simplest addition was a honeypot field.
A honeypot is a normal text input that human visitors do not see and should never fill out. Many basic bots scan a form and put something in every available field.
So I added a field with an ordinary-sounding name:
<div class="contact-trap" aria-hidden="true">
<label for="website">Leave this field empty</label>
<input
type="text"
id="website"
name="website"
tabindex="-1"
autocomplete="off"
/>
</div>
Then I moved it out of sight with CSS:
.contact-trap {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
A real person never interacts with it.
A bot that fills every field announces itself.
The Worker checks the field before doing anything else:
const formData = await request.formData();
if (formData.get("website")) {
return Response.json({ success: true });
}
The response intentionally looks successful.
There is no benefit in telling a bot, “Congratulations, you found the trap.” It can believe the message was accepted and move on with its little robot life.
A honeypot will not stop every bot. More sophisticated scripts know to look for suspicious fields or hidden page elements.
But it is nearly free, invisible to normal users, and removes a surprising amount of low-effort garbage.
That is a good first layer.
Layer two: Cloudflare Turnstile
The next layer was Cloudflare Turnstile.
Turnstile serves roughly the same purpose as a CAPTCHA, but it usually does not ask visitors to identify six blurry motorcycles or determine whether three pixels in the corner count as a traffic light.
I added Cloudflare's script to the page:
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer>
</script>
Then I added the widget inside the form:
<div
class="cf-turnstile"
data-sitekey="MY_PUBLIC_SITE_KEY">
</div>
The site key is public. It belongs in the page and tells Cloudflare which Turnstile widget is being used.
The secret key is different.
That stays in the Worker as a secret and never appears in the HTML, repository, or browser.
When Turnstile approves a submission, it adds a token to the form under:
cf-turnstile-response
The form sends that token along with the name, email address, and message.
But receiving a token is not enough.
The Worker still has to ask Cloudflare whether it is real.
The important part happens in the Worker
It would be easy to add the widget, see the little verification box appear, and declare victory.
That would mostly be decorative security.
A script does not have to load my page or submit the form the way a browser does. It can send its own request directly to the Worker and invent a value for cf-turnstile-response.
So the Worker validates the token with Cloudflare before sending any email:
const token = formData.get("cf-turnstile-response");
if (!token) {
return Response.json(
{ success: false, message: "Verification failed." },
{ status: 400 }
);
}
const verificationResponse = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
body: new URLSearchParams({
secret: env.TURNSTILE_SECRET,
response: token
})
}
);
const verification = await verificationResponse.json();
if (!verification.success) {
return Response.json(
{ success: false, message: "Verification failed." },
{ status: 400 }
);
}
Only after that succeeds does the Worker continue processing the message.
This is the part that actually protects the endpoint.
The page asks the question.
The Worker checks the answer.
Layer three: a small denylist
Turnstile and the honeypot deal with broad categories of automated traffic.
I was also getting repeated messages from a particular sender, so I added a small denylist in the Worker.
The exact addresses do not need to be advertised, but the logic is simple:
const email = String(formData.get("email") || "")
.trim()
.toLowerCase();
const blockedSenders = new Set([
"[email protected]"
]);
const blockedDomains = new Set([
"example-spam-domain.com"
]);
const domain = email.split("@").pop();
if (
blockedSenders.has(email) ||
blockedDomains.has(domain)
) {
return Response.json({ success: true });
}
Again, the response looks successful.
The denylist is not supposed to become a giant hand-maintained database of every bad address on Earth. That would become a second hobby, and I already have enough of those.
It is just a quick way to stop repeat offenders while the broader protections handle new ones.
Blocking only the sender would have been the fastest fix, but also the weakest. Email addresses are cheap and easy to change.
The denylist is useful as one layer, not as the entire wall.
Layer four: slowing repeated submissions
A person may send one message and then correct a typo with a second.
A person is unlikely to send thirty contact-form messages in a minute.
So the Worker also needs a limit on how often the endpoint can be used from the same source within a short period.
The point is not to create a perfect identity system. It is to make rapid automated submissions stop being useful.
Rate limiting also protects more than my inbox. Every accepted request causes the Worker to do more work and attempt an email delivery. Even when the individual cost is tiny, there is no reason to let a script repeat that process indefinitely.
This is one of the advantages of putting the final logic in the Worker. The page, the Turnstile token, the honeypot, the denylist, and the submission rate can all be evaluated before the email-sending code runs.
The expensive or consequential action stays at the end.
That is a useful pattern well beyond contact forms.
The Pages Function stayed simple
One mildly complicated part of this site is that the form does not call the email Worker directly.
The Pages project has a /contact Function, and that Function forwards the request to the separate mcgowenlab-contact Worker through a service binding.
That separation was originally created because the email-sending logic belonged in its own Worker.
The spam protection reinforced the value of that structure.
The Pages Function can remain a small front door:
Receive the form submission
↓
Forward it to the contact service
↓
Return the service response
The contact Worker owns the actual rules:
Check the honeypot
↓
Validate Turnstile
↓
Apply denylist and rate limits
↓
Validate the normal form fields
↓
Send the email
The form does not need to know how email delivery works.
The Pages Function does not need to know every spam rule.
The contact Worker becomes the one place that decides whether a submission is allowed to reach my inbox.
This is more structure than a personal contact form strictly needs, but it is also understandable structure. Each part has one job.
I will take that over one enormous file that does everything and becomes terrifying to touch six months from now.
Testing the unpleasant paths
Testing a contact form normally means filling it out and confirming that an email arrives.
Spam protection requires testing the opposite.
I needed to confirm that an email did not arrive when:
- the honeypot contained a value
- the Turnstile token was missing
- the Turnstile token was invalid
- the sender matched the denylist
- the submission rate was exceeded
Then I tested the normal form again to make sure a real message still made it through.
This sounds obvious, but it is easy to spend all your time proving that the security checks reject bad requests and forget to prove that the site still works for everyone else.
A contact form that blocks all spam by blocking all contact is technically effective.
It is also just a decorative rectangle.
What changed?
The visible form barely changed.
That was the goal.
Behind it, a submission now has to pass several checks:
Human-facing form
↓
Honeypot is empty
↓
Turnstile token exists
↓
Worker verifies the token
↓
Sender is not blocked
↓
Submission rate is reasonable
↓
Required fields are valid
↓
Email is sent
No individual layer is magic.
The honeypot catches simple bots.
Turnstile makes automated submissions harder.
Server-side verification prevents someone from bypassing the page.
The denylist stops known repeat offenders.
Rate limiting reduces the value of hammering the endpoint.
Together, they turn an open email pipe into a small system with rules.
Final thought
Getting spam was annoying, but it was also evidence that the website had crossed another small threshold.
The site was no longer just a collection of files I could see from my own computer. It had a public form, a backend service, and strangers attempting to use it in ways I did not intend.
That is a less charming milestone than publishing the first article, but it is still part of building something real.
The first version asks:
Does it work?
The next version has to ask:
What happens when someone uses it badly?
Apparently the bots were kind enough to remind me.
Νᾶφε καὶ μέμνασ’ ἀπιστεῖν· ἄρθρα ταῦτα τῶν φρενῶν.
“Be sober and remember to distrust; these are the joints of the mind.”
— Epicharmus