Your contact form works. That is the problem. Within a week of going live it starts collecting SEO pitches, crypto giveaways and messages that are nothing but three links and the word “hi”.
The reflex is to bolt on a CAPTCHA. Before you do, it’s worth knowing what that actually costs you, and that the techniques which catch the most spam are invisible to your visitors.
What CAPTCHA actually costs
CAPTCHA is not free. You pay for it in submissions that never arrive.
The people most likely to abandon a form at a puzzle are the ones you least want to lose: someone on a phone with a bad connection, someone using a screen reader, someone whose privacy setup makes the challenge loop forever. They do not email you to complain. They just leave, and you never learn the message existed.
Meanwhile the spam that actually hurts, the targeted stuff sent by a human being paid to fill in forms, walks straight through a CAPTCHA. Solving services cost a fraction of a cent per puzzle. You have added friction for your real visitors and a rounding error in cost for your worst actors.
So the goal is not “block every bot.” It’s to catch the high-volume automated noise silently, and to be careful with everything else.
Technique 1: The honeypot
A honeypot is a form field that no human can see. Bots parse your HTML and fill in everything that looks fillable. Real visitors never touch it because it isn’t rendered anywhere they can reach.
<!-- Bots fill this; humans never see it -->
<input
type="text"
name="botcheck"
style="position:absolute; left:-9999px;"
tabindex="-1"
autocomplete="off"
aria-hidden="true"
/>
Four details in that markup, and each one matters:
- Positioned off-screen, not
display:none. Some bots specifically skip hidden inputs. Moving it out of the viewport is less obvious. tabindex="-1"keeps keyboard users from tabbing into a field they cannot see.aria-hidden="true"keeps screen readers from announcing it. Without this, your honeypot is an accessibility bug: a blind user hears a mystery field, fills it in, and gets silently filtered. That is the worst possible outcome.autocomplete="off"stops a browser from helpfully filling it for a real person.
The trap nobody mentions: the field name has to match what your backend checks for. If you invent your own name, the server has no idea it’s a honeypot and just stores it as an ordinary field. The form looks fine and filters nothing. Formpaste looks for botcheck; whatever you use, check your provider’s docs rather than guessing.
Technique 2: The timing check
People take time to fill in a form. Bots post instantly.
Record when the page loaded, send it along, and compare on arrival:
<input type="hidden" name="_ts" id="form-ts" />
<script>
document.getElementById("form-ts").value = Date.now();
</script>
The threshold should be forgiving. Formpaste treats anything under three seconds as suspicious. That is deliberately low. Someone pasting a prepared message into a single-field form is fast, and you do not want to punish them. Three seconds catches scripted submissions without touching a hurried human.
Note what happens when the timestamp is missing entirely: it scores zero rather than counting against the sender. A visitor with JavaScript disabled should not be treated as a bot.
Technique 3: Link density
Most spam exists to deliver links. Counting them is cheap and surprisingly effective:
const URL_RE = /https?:\/\/[^\s]+/gi;
const links = (text.match(URL_RE) ?? []).length;
Formpaste scores this on a curve rather than a cliff: two links score half, three or more score full. A real customer occasionally sends one link, sometimes two. A message with four is rarely someone asking about your pricing.
Technique 4: Keywords and structural patterns
Keyword matching is the most obvious technique and the most overrated, so it needs the tightest constraints.
Single words match on word boundaries, not substrings. This is not a detail you can skip. A naive text.includes("cialis") is fine, but includes("spec") matching inside “specialist” is the kind of bug that quietly eats legitimate mail. Multi-word phrases like "buy backlinks" are matched as substrings, since the phrase itself is the signal.
Obfuscation gets its own handling, because spammers write v1agra and c4sino:
{ canonical: "viagra", re: /(?<![a-z])v[i1!|]agr[a4@](?![a-z])/i }
Structural patterns catch shape rather than vocabulary, with deliberately conservative thresholds:
// shouting: 4+ consecutive ALL-CAPS words, each 3+ letters
{ name: "shouting", re: /\b[A-Z]{3,}\b(?:\s+\b[A-Z]{3,}\b){3,}/ }
// excessive punctuation: 4+ of the same !, ? or $ in a row
{ name: "excessive_punctuation", re: /([!?$])\1{3,}/ }
Both are tuned so a normal message survives. One angry sentence in caps does not trip the shouting rule, and “ASAP” or “URL” won’t either, because acronyms are usually isolated rather than in a run of four. Someone writing “help!!” is fine; four exclamation marks in a row is a different kind of message.
The rule that matters more than any check
Here’s the part most spam-filtering advice skips, and it’s the difference between a filter that helps and one that quietly loses you business.
No single weak signal should ever reject a message on its own.
Every check above produces false positives. Real people send links. Real people use the word “casino” when they run a casino. Real people fill in forms quickly. If any one of those triggers a rejection, you will lose genuine messages, and you will never find out, because the sender assumes you got it.
The fix is to score rather than judge. Each signal adds points; only the total decides. These are the weights Formpaste uses:
| Signal | Points |
|---|---|
| Honeypot filled | 100 |
| Submitted in under 3 seconds | 40 |
| Duplicate of a recent submission | 40 |
| Link density | 30 |
| Disposable email address | 30 |
| Spam keywords | 25 |
| Structural patterns | 25 |
The quarantine threshold is 100.
Read those numbers and the design becomes clear. Keywords alone score 25, nowhere near 100. A message can mention “investment opportunity” and still land in your inbox, because one weak signal is not evidence. But keywords plus three links plus a disposable address totals 80, and adding a fast submission pushes it to 120. Now it’s a pattern, not a coincidence.
The honeypot is the deliberate exception at 100 points: filling in an invisible field is not something a human does by accident, so it’s conclusive on its own. Note that it still goes through the same additive path rather than a special-case branch. One code path, no surprises.
Quarantine, not deletion
Even a well-tuned score gets things wrong. So the question becomes: what do you do with a message you are 80% sure is spam?
The wrong answer is to delete it. A silent drop means a real customer wrote to you, saw a success message, and waited for a reply that will never come. You cannot detect this failure. You cannot measure it. You just lose the business and never know.
The better answer is a quarantine folder. Suspicious messages are held and visible rather than destroyed, so a false positive costs a review click instead of a customer. On Formpaste, quarantine review is available on the free plan (read-only), and releasing a message back to your inbox, which also whitelists that sender, is a Pro feature.
Whatever provider you use, the question to ask is: what happens to a message that looks suspicious but isn’t? If the answer is “it’s deleted,” you’re trading an invisible problem for a visible one.
What you can’t do without a server
Two of these need a backend, and it’s worth being straight about that.
Rate limiting requires shared state across requests. Formpaste runs several layers: 5 submissions per 10 seconds per form key, 10 per minute per IP, plus a sustained per-key limit to catch a form hammered steadily rather than in bursts. You cannot do any of this from a static page.
Duplicate detection needs to remember what was submitted before, which again means storage. It’s worth 40 points because the same message sent repeatedly is one of the strongest signals there is.
Honeypot markup and timing fields you can add to any static HTML page today. Scoring, rate limiting and quarantine need something server-side, which is exactly what a form backend is for.
Putting it together
If you take one thing from this: score, don’t judge. Collect several weak signals, add them up, and act only on the total. Then hold the borderline cases somewhere you can see them instead of deleting them.
That approach catches the overwhelming majority of automated spam, costs your real visitors nothing, and fails safely when it’s wrong. No puzzles required.
If you’re adding a form to a static site and want the full setup, the step-by-step guide to a contact form without a backend covers the markup, and the spam filtering is on by default.