How to Stop Contact Form Spam: Honeypot, Fill Time and CAPTCHA
Protect your contact form from bots and spam with a honeypot field, a fill-time check and a CAPTCHA.
Nisuform team
8 min read

Short answer: add a hidden honeypot field that people never see, record how long the visitor spent on the form, and drop any submission that fills the honeypot or arrives in under two seconds. Accept posts only from your own domains, rate limit each sender and send messages full of links to a spam folder. Add Cloudflare Turnstile or reCAPTCHA only if spam still gets through. The checks have to run where the form is received, because bots can skip your page's JavaScript. Nisuform includes all of them on every plan.
<form action="https://api.nisuform.com/s/YOUR_FORM_KEY" method="POST">
<label for="email">Email</label>
<input id="email" type="email" name="email" required>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="display:none">
<input type="hidden" name="_elapsed">
<button type="submit">Send</button>
</form>
<script>
const form = document.querySelector('form')
const shownAt = Date.now()
form.addEventListener('submit', () => {
form.elements._elapsed.value = String(Date.now() - shownAt)
})
</script>Where contact form spam comes from
Spam reaches a contact form in four ways, and each one needs a different check:
- Form-filling bots. A script finds your form, fills in every field and submits it within milliseconds. This is the most common kind, and the cheapest to stop.
- Direct posts. A script skips your page and sends requests straight to the URL in your form's
action. Nothing on your page runs, so only checks on the receiving end apply. - Link spam. Messages stuffed with URLs, often in forum link code like
[url=...], written to sell something or to plant links. - People. Sales pitches and SEO offers typed by hand, usually the same text sent to thousands of sites. No CAPTCHA stops them, because they are real visitors.
No single check catches all four, so the usual setup is layers: invisible checks that cost visitors nothing, then a CAPTCHA only for forms that still get through.
| Check | Cost to visitors | Catches |
|---|---|---|
| Honeypot field | None | Bots that fill in every field |
| Fill time | None | Bots that submit in milliseconds |
| Allowed websites | None | Your form copied onto other sites |
| Rate limits | None | Floods from one sender |
| Link and duplicate rules | None, flagged messages are kept | Link spam and copy-paste pitches |
| Turnstile or reCAPTCHA | A script on the page, sometimes a click | Bots that get past everything above |
The checks have to run on the server
A honeypot check in the browser, like if (honeypot.value) return in a submit handler, stops nothing. Bots that post directly never run that code, and bots that do run it can skip it. The browser can only collect the signals, like the honeypot's value and the fill time. Whatever receives the form has to read them and decide.
On a site with its own server, that is a few lines in your form route. On a static site, it is a serverless function you write, or a form backend that runs the checks for you.
Add a honeypot field
A honeypot is an input that people never see, so they leave it empty. Many bots fill in every input they find, so a value in it means a bot sent the form.
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="display:none">A few details make it work:
- Hide it with CSS, not
type="hidden". Bots know people can't fill in hidden inputs and skip them. - Keep
tabindex="-1"so keyboard users never land on it, andautocomplete="off"so the browser doesn't fill it in. - On your own server, pick a name that browsers don't autofill, so nothing like
email,name,phoneoraddress. Otherwise a visitor's browser may fill the trap and their message is dropped. Nisuform reads the honeypot from a field named_gotcha.
Some bots check whether a field is visible and skip fields hidden with display:none. Moving the field off-screen catches more of them, but screen readers can still reach an off-screen field, so hide the wrapper from them and say what the field is for:
<div aria-hidden="true" style="position:absolute; left:-10000px">
<label for="website-check">Leave this empty</label>
<input id="website-check" type="text" name="_gotcha" tabindex="-1" autocomplete="off">
</div>When the honeypot has a value, answer as if the submission worked and don't store it. A bot that gets an error can retry without the field, while one that gets a success response has no reason to change anything.
Check how long the form took
A person needs at least a few seconds to type an email address and a message. A bot fills in the whole form in a few milliseconds. The script in the example at the top notes when the page loads and, on submit, writes the milliseconds since then into _elapsed.
Drop submissions that took less than about two seconds. A higher limit catches a few more bots but starts to catch people who use autofill and paste a short message. If the field is missing, skip the check, so the form keeps working for visitors without JavaScript.
The fill time comes from the browser, so a bot written for your form can send any number it likes. It still catches generic bots, because they don't know the field means anything.
A honeypot and fill-time check on your own server
If your form posts to your own function, both checks fit in a few lines. This Cloudflare Worker sends every request to the same thank-you page and only saves the ones that pass:
export default {
async fetch(request) {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 })
}
const data = await request.formData()
const elapsed = data.get('_elapsed')
const isBot =
Boolean(data.get('_gotcha')) || (elapsed !== null && elapsed !== '' && Number(elapsed) < 2000)
if (!isBot) {
await saveAndNotify(data)
}
return Response.redirect('https://example.com/thanks', 303)
},
}saveAndNotify is your own code, which stores the message and sends you an email. The same logic works in a Netlify or Vercel function, or in an Express route.
Accept submissions only from your website
A form's endpoint sits in your page source, so anyone can copy the form onto another site. Browsers send an Origin header, or a Referer header when there is no Origin, with every form post, and pages can't fake them. Compare it with a list of your own domains and reject posts from anywhere else.
In Nisuform, add your domains under Settings, Spam protection, Access rules. See allowed websites. Add both https://example.com and https://www.example.com if your site answers on both, and http://localhost:3000 to test locally.
This stops other sites from embedding your form, but a script can send any headers or none, so it doesn't stop a bot on its own. If an endpoint is already on spam lists, rotate the form's key to give it a new URL.
Rate limit senders and flag links
These checks look at how often someone submits and what they send:
- Rate limits. Cap how many submissions one IP address can send to a form each minute, and how many the form accepts each hour. Nisuform's Free plan allows 5 a minute from one address to one form, and 200 an hour to one form from everyone, which no person filling in a form by hand comes close to.
- Links. Real contact messages rarely include more than a link or two. Nisuform flags more than 5 links across all answers, and any forum link code like
[url=...]or HTML like<a href="...">. - Duplicates. Sales pitches sent by people are usually pasted from a template. Flag a submission with exactly the same answers as one the form received in the last 24 hours.
Keep what these checks flag instead of deleting it. A customer who pastes a list of product links, or sends the same question twice, is still a customer. Nisuform stores flagged messages in a Spam folder that sends no notifications and doesn't count toward your monthly limit, and Not spam moves one back to the inbox.
When to add Turnstile or reCAPTCHA
If spam still gets through all of the above, it usually comes from bots that run a real browser or were written for your form. At that point a CAPTCHA is worth adding, even though it puts another script on the page and sometimes asks visitors for a click.
Cloudflare Turnstile is the easier choice. It is free with unlimited challenges and needs only a Cloudflare account, and most visitors never see a challenge. Google reCAPTCHA is worth keeping if you already have keys. Its free tier covers 10,000 checks a month, and the v3 version blocks real people with low scores unless you build a fallback.
Other free reCAPTCHA alternatives include hCaptcha, which has a free plan, and ALTCHA, an open-source proof-of-work widget that you host and verify yourself. Whichever you pick, the widget blocks nothing until a server checks each token with your secret key. Nisuform verifies Turnstile and reCAPTCHA tokens on every plan.
Spam protection in Nisuform
Every Nisuform form runs the honeypot, fill-time check, rate limits, and link and duplicate rules on every plan, with nothing to set up. Allowed websites, blocked IP addresses and countries, and Turnstile or reCAPTCHA are optional, per form.
Submissions caught by the honeypot or the fill-time check are dropped and get a normal success response, so bots can't tell what gave them away. Everything flagged for links or duplicates goes to the Spam folder. How spam filtering works lists every check in the order it runs.
Checklist before you ship
- The honeypot is hidden with CSS, not
type="hidden", and its name isn't one that browsers autofill. - Your script sets
_elapsedwhen the form is sent, and the form still works without JavaScript. - The checks run where the form is received, and caught bots get a normal success response.
- Allowed websites list every domain your form runs on, including
www, plus your dev server for testing. - Flagged messages go to a spam folder you look at, not straight to the bin.
- You tested the honeypot. Remove
display:nonein your browser's developer tools, fill in the field and submit. The response looks normal and nothing reaches your inbox.
Frequently asked questions
Why is my contact form suddenly getting spam?
Bots find forms by crawling the web, so spam often starts some time after a form goes live and grows as more bots find it. Once bots know a form's URL, they keep posting to it even if you change the page. A honeypot and a fill-time check stop most of it, and giving the form a new endpoint leaves the old URL behind.
Does a honeypot field still work?
Yes, against generic bots that fill in every field they find, which are the usual source of contact form spam. It doesn't stop bots written for your form or people typing by hand, so pair it with a fill-time check, link rules and, if needed, a CAPTCHA.
Is a honeypot field bad for accessibility?
Not when it is hidden with display:none, because screen readers and keyboards skip it. If you move it off-screen instead, wrap it in an element with aria-hidden="true", give the input tabindex="-1" and label it "Leave this empty".
What is the best free alternative to reCAPTCHA?
Cloudflare Turnstile, for most sites. It is free with unlimited challenges, works without moving your site to Cloudflare, and most visitors never see a challenge. Like reCAPTCHA, every token has to be verified on a server with your secret key.
Can I block form spam on a static site with no server?
Not with browser code alone, because bots can post to your form's URL without loading the page. The checks have to run where the form is received. Post the form to a form backend that runs them, like Nisuform, or write a serverless function.
Will spam filters block real messages?
They can, which is why messages that only look like spam, such as ones with many links, should go to a spam folder instead of being deleted. Nisuform drops only honeypot and fill-time hits, which people almost never trigger, and keeps everything else it flags in a spam folder you can review.

