What Actually Stops the Bots on My Contact Form

What Actually Stops the Bots on My Contact Form

A message arrived through the contact form on this site that no person had written. A script had fetched the page, read the security token out of the HTML, kept the session cookie, waited a few seconds and posted. It passed every check the form had and went straight to my inbox.

Below is everything that now stands between the form and the inbox. Numbers and exact rules stay private, for the obvious reason; the approach is the part worth copying.

Four checks, all passed

The form was not unguarded. It had a CSRF token, issued with the page and checked on submission; a hidden field that people never see and careless bots fill in; a minimum time between loading the page and sending it, because scripts tend to post instantly; and a limit on messages per visitor.

The script beat all four without trying. Reading a hidden input is one line of code. Leaving a field empty is free, and so is waiting. A per-visitor limit means nothing when one message is all a bot wants.

Each of those checks stops a careless bot. None of them costs a careful one anything.

So the question changed from “which check is missing?” to “what does each check make a bot pay?”

A real bot check, verified on the server

The first new layer is Cloudflare Turnstile, a free challenge that runs in the visitor’s browser without asking anyone to pick out traffic lights, and adds a token to the form when it is satisfied.

That token proves nothing until the server checks it; a bot can ignore the widget and post anyway. So the site sends it to Cloudflare, with a secret key the browser never sees, and accepts the message only on a yes. It also checks which site the token was solved on, so one earned on a copy of the form elsewhere is worthless here. If the service cannot be reached, the check fails closed.

Two details matter. Tokens are single-use, so the page fetches a fresh one after every attempt; otherwise a person fixing a typo would fail a check they had already passed. And the verification runs after the cheap checks, so a throttled or filtered submission never costs a network call.

Rules about what is in the message

A bot check is about who is sending. The second layer reads what was sent.

The rules are deliberately conservative, because a filter that loses one real enquiry has cost more than all the junk it caught. Most are about links, since nearly all form spam exists to put one in front of you: how many there are, where they sit, and whether they hide their destination. The rest are patterns that nobody writing about a project would use, matched on whole words so that an innocent word with a rude one inside it is left alone.

A link problem gets an honest answer asking the sender to remove the links or email me directly, which a real person can fix in a moment.

Throttling, per visitor and overall

The per-visitor limit stayed, and got tighter. Beside it is a second limit on messages from everyone together. A botnet spreads its requests across many addresses, so a per-visitor limit only ever sees one of them; a site-wide limit sees the flood. It sits well above a real hour of enquiries, and anyone who meets it is pointed to my email address.

A fake success for obvious spam

When the hidden field has been filled in, or the content rules are sure, the form does not say no. It returns exactly what a real success returns.

An error is information: a bot that gets one learns which rule it tripped and tries again without it. A bot that gets a success learns nothing, and whoever runs it believes the job is done. Every dropped submission is still logged with its reason, somewhere the web server will not serve, so a real person who trips a rule can be found and answered.

Be clear with anyone who might be a person. Tell anything that cannot be one nothing at all.

The empty token that matched

While I was in there I found an unrelated bug that looks like correct code. PHP’s hash_equals compares strings in constant time, the right way to check a secret token. A typical check:

// Looks safe. Is not.
if (!hash_equals($_SESSION['token'] ?? '', $_POST['token'] ?? '')) {
    reject();
}

Picture a request with no session cookie and no token in the form. Both sides fall back to an empty string, hash_equals('', '') is true, and the check passes: a missing secret has become a matching one. The fix: treat a missing expected value as a failure before comparing anything.

$expected = (string) ($_SESSION['token'] ?? '');
if ($expected === '' || !hash_equals($expected, (string) ($_POST['token'] ?? ''))) {
    reject();
}

Here another check happened to stop cookie-less requests first, which is luck rather than design, and the script in this story had a real session anyway. The lesson reaches well past CSRF, to API keys, signed links, reset codes and tokens on scheduled jobs. Decide what “missing” means before you decide what “equal” means.

Every layer sends a bill

No single check stopped anything; the script walked past four of them. What works is a stack where each layer charges a bot something different. The challenge makes it behave like a real browser, which is costly in bulk. The content rules cost it the link it came to deliver. The throttles cost it time and addresses. The fake success costs it feedback, so it cannot tune itself against the rest.

A bot can afford any one of those. Paying all of them for one message to a one-person inbox is a bad trade, and making it a bad trade is the whole job. If you are a person with a project, the contact page is open.