My contact form was spammed within days. Here's the free fix.
How bots found a brand-new site almost immediately, why the usual spam traps didn't stop them, and the four checks that did, at no extra cost.
- Published
- Reading time
- 6 min
On this page
This site has one interactive feature: a contact form that forwards messages to my inbox. It runs on API Gateway, a small Lambda function and Amazon SES, and it had the usual spam traps from day one.
Within days of launch, my inbox started filling with messages like this: a few paragraphs of Russian-language text about velvet packaging or building insulation, a link every other sentence, signed by “HarryNeulp” or “JamesneotH”.
It wasn’t personal, and it wasn’t a mistake in the traps’ logic so much as in where they ran. Here’s what happened and the fix that stopped it, with no new services and no extra cost.
How they found a site nobody had heard of
The site was days old, with almost no traffic. The bots found it because of how HTTPS works.
Every publicly trusted TLS certificate is recorded in Certificate Transparency logs, public append-only lists that browsers rely on to catch misissued certificates. Anyone can watch them. Spam and scanning tools do. Researchers have measured scans arriving only minutes after a certificate is issued. When this site got its certificate from AWS Certificate Manager, it effectively announced itself.
From there, finding the form is easy: crawl the pages, look for a form with name, email and message fields, and note where it submits.
The messages themselves are SEO link spam. The software that sends them posts to any form it finds, hoping the text ends up published somewhere as a comment and earns its customers a backlink. Names like these, a common first name plus random letters, are typical of automated spam tools. Nobody chose this site. It was simply new and had a form.
Why the traps didn’t work
The form already had two classic traps:
- A honeypot: a hidden field that people never see and bots tend to fill in. If it has a value, the message is dropped.
- A minimum fill time: the page stamps the time it loaded, and a message sent less than three seconds later is treated as a bot.
Both are good ideas. Both only work if the bot loads the page. These bots didn’t. They posted straight to the form’s API endpoint with just the fields they cared about: no hidden field, no timestamp.
And the code only checked those fields when they were present. A missing timestamp skipped the timing check entirely. The Origin check had the same gap: it rejected requests that claimed to come from another site, but let through requests that didn’t say at all.
That’s the real lesson here, and it applies well beyond contact forms: a check that only runs when the attacker cooperates isn’t a check.
The fix: four checks, in order
1. Require the Origin header. Modern browsers send it on form submissions and fetch POSTs. Scripts that post directly usually don’t bother. A missing Origin is now treated as a bot. (An Origin from a different site still gets an error, as before: that’s a misconfigured page, not a bot to fool.)
2. Keep the honeypot. It still catches bots that do load the page.
3. Require a sane timestamp. Submissions from the page’s own script must carry the load time, and it must be at least 3 seconds and at most 24 hours old. Plain form posts from people with JavaScript turned off have no timestamp and are still accepted, because they still have to pass the other checks.
4. Drop link spam. Three or more links in a message, a link in the name field, or any link in a message that’s more than 30% Cyrillic. A real message with one or two links still gets through, and so does a real message in Russian without links.
The core of it is a few lines in the Lambda function:
// Browsers always send Origin on a form POST or fetch; scripts posting directly usually don't.
if (!origin) {
deps.log('spam', { reason: 'no_origin' });
return ok(wantsJson);
}
export function linkSpam(name: string, message: string): string | undefined {
if (HAS_URL.test(name)) return 'link_in_name';
const links = message.match(URL_RE) ?? [];
if (links.length >= MAX_LINKS) return 'many_links';
if (links.length > 0 && cyrillicShare(message.replace(URL_RE, '')) > 0.3) return 'cyrillic_with_link';
return undefined;
}
Two design choices matter as much as the checks.
Fail silently. A submission blocked by any of the four checks gets exactly the same “sent” response as a real one. If the bot got an error, its operator would learn which check failed and adjust. This way, it learns nothing.
Log the reason, not the message. Each blocked submission writes one line, such as {"event":"spam","reason":"no_origin"}, with no names, emails or text. That’s enough to see what’s happening and tune the rules, without storing anyone’s personal data.
What happened next
Here are the numbers from the function’s logs.
| Before the fix (about 5 days) | After the fix (10 days measured) | |
|---|---|---|
| Messages emailed to me | 41, nearly all spam | 1, my own test |
| Blocked silently | 0 | 39 |
Of the 39 blocked submissions, 33 had no Origin header: bots posting straight to the API. The other 6 filled in the hidden field: bots that did load the page, and that the honeypot now catches as intended.
The link-spam rule hasn’t fired once. The first two checks stopped everything before it got that far. It stays in as the backstop for the day a bot learns to send an Origin header and leave the hidden field alone.
To check it after deploying, I sent a fake bot message with curl. It got {"ok":true} back, no email arrived, and the log showed "reason":"no_origin". A real message from the form, sent a minute earlier, arrived normally.
If they adapt
These checks stop the cheap, high-volume tools, which is most of what a small site sees. A determined bot can send an Origin header, load the page and wait three seconds. If that happens, the next steps, in order of cost:
- Rate limits. The API already allows one request per second across all visitors, with bursts of five. That caps the damage, but it doesn’t stop slow spam, and a burst from a bot can briefly block a real visitor too.
- AWS WAF in front of CloudFront, with a per-IP rate-based rule and AWS’s managed IP reputation list. Expect about $7 a month for a small site: $5 for the web ACL, $1 for each of the two rules, and $0.60 per million requests. Or put the distribution on CloudFront’s flat-rate Free plan, which includes WAF with rate limiting at no charge.
- A CAPTCHA such as Cloudflare Turnstile. It works, but it means loading a third-party script, and this site keeps a strict Content Security Policy that allows none.
The same on Azure
The approach doesn’t depend on the cloud. On Azure, the form would typically run as an Azure Static Web Apps site with an Azure Functions API, and the same four checks go in the function unchanged. The paid next step there is a WAF policy on Azure Front Door, which carries Front Door Standard’s monthly base fee, so it’s worth exhausting the free checks first on both clouds.
A checklist for any public form
- Every check runs whether or not the field it checks is present, except a deliberate, documented fallback (here, form posts without JavaScript)
- Origin required, not just validated when present
- Honeypot and minimum fill time, for the bots that do load the page
- A simple link-spam rule tuned to what you actually receive
- The same success response for blocked and real submissions
- One log line per blocked submission, with the reason and no personal data
- A rate limit on the endpoint
- A test that posts like a bot, run after every deploy
Sources: Certificate Transparency (Google), Scheitle et al., “The Rise of Certificate Transparency and Its Implications on the Internet Ecosystem” (IMC 2018), Origin header (MDN), API Gateway throttling, AWS WAF pricing, CloudFront flat-rate plans, Azure Front Door pricing.
- aws
- lambda
- security
- spam
- serverless
- contact-form