You launch a landing page on Monday. By Wednesday your contact form has 40 submissions and 38 of them are offers to rank you first on Google. The two real ones are buried somewhere in the middle. Nobody has bought your product yet and you are already doing inbox triage.
The reflex is to bolt on reCAPTCHA. That means loading a third-party script from Google on the page whose conversion rate you are trying to protect, and (in the EU) explaining that script in a consent banner. For a landing page whose entire job is to collect a few hundred signups, that trade is bad.
Three layers, none of which involve a third-party script, remove almost all of it.
Add a CSS-hidden honeypot field named with an underscore prefix, reject any submission that fills it, and reject any submission that arrives less than three seconds after the form rendered. OperatorStack strips every underscore-prefixed field before storing, so the honeypot never lands in your data, and the public form endpoint already rate limits to 100 requests per minute per IP. No reCAPTCHA, no consent banner, no server.
Why reCAPTCHA Is the Wrong First Move
reCAPTCHA solves a problem most pre-launch sites do not have yet. It is built for targeted, high-value abuse: credential stuffing, ticket scalping, account farming. The spam on a new landing page is almost never targeted. It is a crawler that found your form in a scrape of newly registered domains, filled every input it could parse, and posted.
Loading Google's script to fight that costs you three things. It adds a render-blocking third-party request to your critical path. It puts a Google tracker on the page, which in the EU means it belongs in your consent banner, and a consent banner measurably reduces signups. And it puts a puzzle in front of the people you actually want.
The "invisible" reCAPTCHA v3 is not invisible to your privacy policy. It still loads Google's script and still scores visitors using signals collected across the web. If you are avoiding Google Analytics for cookie-banner reasons, adding reCAPTCHA re-opens the same question.
Layer 1: A Honeypot Field the Server Strips For You
A honeypot is an input that no human can see, so no human will ever fill it. Bots parse the DOM, see an input, and fill it. Any submission with a value in that field is automated.
The naming matters here, and this is where OperatorStack does something useful. Every field whose name starts with an underscore is stripped from the stored submission before it is written:
# api/app/services/forms.py
clean_data = {k: v for k, v in data.items() if not k.startswith("_")}
That rule already exists for the reserved fields (_redirect, _subject, _formName). Name your honeypot with the same prefix and you get the same treatment for free. It never reaches your submission data, never shows up as a column in your export, and never becomes a field in the form's inferred schema.
Add the field:
<form id="contact-form">
<input type="email" name="email" placeholder="your@email.com" required />
<textarea name="message" placeholder="What do you need?"></textarea>
<div class="hp-wrap" aria-hidden="true">
<label for="_company_website">Leave this field empty</label>
<input
type="text"
id="_company_website"
name="_company_website"
tabindex="-1"
autocomplete="off"
/>
</div>
<button type="submit">Send</button>
</form>
<style>
.hp-wrap {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>
Three details in that markup do the work. aria-hidden="true" and tabindex="-1" keep screen readers and keyboard users out of it, so the honeypot never becomes an accessibility bug. autocomplete="off" stops a password manager from helpfully filling it and flagging a real person as a bot.
Position the field off-screen instead of using display: none or type="hidden". The better bots skip both of those, because they are the two things every honeypot tutorial from 2012 recommends. An input that is technically rendered but visually off-canvas still looks fillable to a DOM parser.
Name it something a bot wants to fill. _company_website and _phone2 get filled far more often than _honeypot does.
Layer 2: A Submit-Timing Gate
The second signal is speed. A person has to read the labels, decide what to write, and type an email address. That takes seconds. A bot posts the instant it finishes parsing.
Record when the form rendered, and check the gap on submit:
const form = document.getElementById("contact-form");
const renderedAt = Date.now();
const DONE = "<p>Thanks, we will be in touch.</p>";
form.addEventListener("submit", async (event) => {
event.preventDefault();
const fields = Object.fromEntries(new FormData(form));
// Bot filled the invisible field. Show success, send nothing.
if (fields._company_website) {
form.innerHTML = DONE;
return;
}
// Nobody reads a form and types an email in under three seconds.
if (Date.now() - renderedAt < 3000) {
form.innerHTML = DONE;
return;
}
await OperatorStack.submitForm("YOUR_FORM_KEY", fields);
form.innerHTML = DONE;
});
Show the success message either way. A bot that gets an error learns to retry with different input. A bot that gets a cheerful "thanks" marks your form as solved and moves on, and the real person behind a false positive is not told their message failed.
Three seconds is the number to start with. It is comfortably above what a bot spends and comfortably below what a person spends. If you are seeing false positives, raise it. If a determined bot starts sleeping before it posts, no timing threshold will help and you have moved into the targeted-abuse case where a challenge is genuinely warranted.
Note that when the honeypot is empty, FormData still includes _company_website: "" and it still gets posted. The underscore rule from layer 1 means you do not have to strip it yourself.
Layer 3: The Rate Limit You Already Have
The public form endpoint is rate limited per IP before any of your code runs:
# api/app/middleware/rate_limit.py
RateLimiter(max_requests=100, window_seconds=60)
That is 100 requests per minute per IP on a 60-second sliding window, shared across the public endpoints. Cross it and the request gets a 429 with {"detail": "Too many requests"} and never touches your form.
Be clear about what this does and does not cover. It stops one script hammering one endpoint, which is the shape of most drive-by form abuse. It does nothing against a distributed flood from a hundred addresses posting twice each, because no individual IP crosses the line. That is what layers 1 and 2 are for: they work per submission, not per source.
Plain HTML Forms With No JavaScript
If your form posts directly to POST /v1/f/{form_key} with no JavaScript at all, layers 2 and 3 still apply but layer 1 changes shape. There is no client-side code to inspect the honeypot and abort, so a bot that fills it will still get its submission recorded.
What you keep is the cleanup. Because the field is underscore-prefixed, the junk value is stripped before storage, so your submissions and your CSV export stay clean even when the bot got through.
The form key is meant to be public. POST /v1/f/{form_key} deliberately does no origin check, which is what makes plain HTML forms work from Framer, Webflow, GitHub Pages, or anywhere else you paste them. Treat the key as public and defend the form, not the key.
If you want actual rejection, you need the eight lines of JavaScript from layer 2. On a static site that is a <script> tag, not a backend.
When Spam Still Gets Through
Some will. Two dashboard controls handle it.
Archive it. Submission status moves between new, read, and archived in the Forms tab. Archiving clears your working list without destroying the record. There is no per-submission delete, so archive is the triage move, not a cleanup you can undo by deleting.
Kill the form. Every form has an enabled toggle. Flip it off and the endpoint returns 403 with "Form is not accepting submissions" until you turn it back on. Under an active flood this is the fastest lever you have, and it costs you nothing but the form while you add the honeypot.
What Not To Bother With
Blocking disposable email domains. Maintaining a blocklist is a permanent chore, the lists go stale within weeks, and you will reject real people who use a forwarding address for privacy.
Regex-tightening the email field. type="email" already gives you native browser validation. Writing a stricter pattern mostly rejects valid addresses with plus-addressing or new TLDs.
Hiding the form key. It is public by design. Obfuscating it in your bundle costs you an afternoon and buys nothing.
A math question. "What is 4 + 7" is trivially solved by any bot that can parse the label, and it makes a real person do arithmetic to contact you.
Frequently Asked Questions
Do I need reCAPTCHA to stop form spam?
No. Most spam hitting a pre-launch landing page comes from generic bots that fill every field in the DOM and submit instantly. A honeypot field plus a submit-timing gate catches those without loading a third-party script or adding a consent banner. Save reCAPTCHA for when you have proof that targeted spam is getting through.
Does a honeypot field actually work against bots?
Against generic form-scraping bots, yes. They parse your HTML, fill every input they find, and post. A field no human can see should always be empty, so any submission that fills it is automated. It does not stop a human spammer or a bot written specifically for your site, which is why you pair it with a timing gate and the rate limit.
Is it a problem if someone finds my form key?
No. The key in POST /v1/f/{form_key} is public by design so plain HTML forms can post from any page. It grants nothing except the ability to submit to that one form. Your protection is the rate limit, the honeypot, and the per-form enabled toggle, not key secrecy.
How do I stop spam on a plain HTML form with no JavaScript?
You cannot reject a submission client-side without JavaScript. An underscore-prefixed honeypot still helps, because OperatorStack strips those fields before storing and the junk never pollutes your data. To block the submission outright, add the timing-gate snippet above.
What do I do with spam that already landed in my dashboard?
Set the submission status to archived in the Forms tab. There is no per-submission delete, so archiving is the triage move. If you are under an active flood, flip the form's enabled toggle off and the endpoint returns 403 until you turn it back on.