A contact form submission came in claiming to be Isabella Thompson. The reply-to was madamtaisia@mail.ru. The body said "I would like more information. Please contact me by email."
Every authentication check passed. SPF, DKIM, DMARC, ARC, all green. That stops being surprising once you see why: the contact form mails us through our own Resend account, so the cryptography validates our infrastructure. It says nothing about who filled in the form.
The interesting part is that the form already had a honeypot, and the honeypot was working correctly.
A honeypot only catches a bot that loads your page
A honeypot is a hidden field. Humans never see it, bots fill it in, you reject anything that arrives with it set.
That logic has a hole. Most contact form spam never renders your page. It POSTs straight at the handler with a body it composed itself. So the hidden field is not filled in, it is absent. And "is this field empty?" answers yes.
The check runs, passes, and lets the message through. Nothing is broken. The defence just never met the attacker.
Proof of render
The fix is to require something the page hands out.
We mint a signed, timestamped token when the form renders, and require it back on submit. A request that never loaded the form has no token and goes nowhere. The token carries the moment it was issued, so the fill time comes along for free. A submission that returns in under three seconds did not come from someone typing.
That is the whole idea. It is packaged as @profullstack/form-guard, zero dependencies, and it runs on Node, Bun, Deno, Cloudflare Workers and the Next.js edge runtime.
There are five layers and only four of them can block:
- proof of render token, catches direct POSTs
- fill time floor, catches instant submits
- honeypot, catches the bots that do render
- per IP rate limit, catches floods
- content scoring, which only tags
That last one matters. Every content signal has an innocent explanation. Real people write short messages, real people have mail.ru addresses, real people paste links. So scoring adds a tag to the subject and a block naming the sender IP, user agent, fill time and which signals fired. It never drops a message. Blocking is left to the checks that key on things no human visitor does.
Two things to get wrong
The page that renders the form has to be dynamic. A cached or prerendered page hands every visitor the same token, and that token dies when it expires. On one site the form is a section of the homepage, so protecting it moved the homepage from static to server rendered. That is a real cost, worth deciding on rather than absorbing.
The page and the route also have to share one guard. If the binding or the field names drift apart, every genuine submission is rejected, silently. That is the worst failure mode available here, so the config lives in one module that both sides import.
What the rollout turned up
Eighteen properties have a live contact form. Eight of them route through one shared library, which made that the place to fix it once.
Going through the rest found problems that had nothing to do with spam. Three sites render an hCaptcha widget whose response the server never verifies, so visitors have been solving a puzzle that gates nothing. Three more interpolate the submitter's name and message straight into the notification email's HTML. One contact form logs the submission and returns success without sending anything, so every message anyone ever sent through it was discarded while the sender was told it arrived.
Reading eighteen implementations of the same feature is a good way to find out what you actually shipped.
Top comments (0)