Stop contact form spam6 min read
How to stop spam on a static site contact form
Stop spam on a static site form even though it can't keep a secret or count posts, with a honeypot partial, a safe script and server-side checks.
A static site has no server, so nothing on the page can keep a secret key, verify a captcha token or count posts per visitor. Those checks must run somewhere else: a function on your host, or a form backend that your form posts to. Put an accessible honeypot in the form either way, and keep the form working without JavaScript.
What's different on a static site
On a static site, every byte you deploy is public. Anything in your HTML or JavaScript can be read and skipped by a bot, so client-side spam checks are advice, not enforcement. The three checks that matter most all need a server:
- Verifying a captcha token needs a secret key, which can't live in a public file.
- Counting posts per visitor needs storage that survives between requests.
- Reading a honeypot needs code that runs after the post arrives, out of the bot's reach.
Static hosts also don't accept posts to your pages. Vercel and GitHub Pages answer a form POST to a static page with 405 Method Not Allowed, so the form needs an endpoint before spam is even a question.
- A static page that posts to itself reaches a host that runs no code, so the post fails with 405 or goes nowhere.
- With a host function, the page posts to a function that holds the secret, reads the honeypot and verifies the token, then sends the message on.
- With a form backend, the page posts straight to the backend, which runs the honeypot, scoring, rate limit and human check before it emails you.
Your options
| Option | Spam checks | Trade-offs |
|---|---|---|
| Netlify Forms | Akismet on every post, plus a netlify-honeypot field and reCAPTCHA v2 if you add them | Only on Netlify. Moving host means rebuilding the form. See Netlify Forms alternatives |
| A function on your host | Whatever you write: honeypot, token checks, a rate limit with a store | You write and maintain it, and need an email or storage service behind it |
| A form backend, such as Ikiform | Honeypot, scoring, rate limits, allowed domains and a human check, with no code | Another service, priced per submission |
The form, as a reusable partial
Whichever endpoint you choose, write the form once as a partial or include, so every page uses the same honeypot. The version below posts to Ikiform. To use a function instead, change action to the function's path and drop _redirect.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" class="contact">
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
<div class="hp" aria-hidden="true">
<label for="hp-field">Leave this empty</label>
<input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
</div>
<input type="hidden" name="_redirect" value="{{ "thanks/" | absURL }}" />
<p role="status"></p>
<button>Send message</button>
</form><style>
.hp {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>- The honeypot sits off-screen, out of the tab order and hidden from screen readers. The honeypot guide explains each attribute.
_redirectsends people to your thank-you page. Ikiform follows it only for anhttps:address on one of the form's Allowed domains.- The Hugo, Jekyll and Eleventy guides cover setup, shortcodes and GitHub Pages.
Enhance it without breaking it
A script can keep visitors on the page, but it must never be the only way the form works. This one posts with fetch and falls back to a normal browser post in two cases: when the network call fails, and when Ikiform's human check asks for a token with challenge_required. A normal post gets Ikiform's check page instead, so you can turn the check on without changing the script.
<script>
document.querySelectorAll("form.contact").forEach((form) => {
const status = form.querySelector("[role=status]");
const button = form.querySelector("button");
form.addEventListener("submit", async (event) => {
event.preventDefault();
button.disabled = true;
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
form.replaceChildren("Thanks. We'll reply by email.");
return;
}
if (result.error.code === "challenge_required") {
form.submit();
return;
}
const wait = response.headers.get("Retry-After");
if (response.status === 429 && wait) {
status.textContent = `Too many messages. Try again in ${wait} seconds.`;
} else {
status.textContent = result.error.message;
}
} catch {
form.submit();
return;
}
button.disabled = false;
});
});
</script>form.submit()sends the form without firing thesubmitevent again, so the fallback can't loop.- A
429shows how long to wait fromRetry-After, which Ikiform exposes to browser code. - The success line replaces the form, so nobody sends it twice by accident.
If you write a host function
Netlify, Cloudflare Pages and Vercel all run small functions next to static files. A function is your own backend, so it owns the spam checks. This Netlify Function reads the honeypot, drops link-stuffed messages, verifies a Turnstile token and emails you through Resend. Every caught post gets the same thank-you redirect as a real one.
import type { Config, Context } from "@netlify/functions";
const LINK = /https?:\/\/|www\./gi;
export default async (req: Request, context: Context) => {
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
const form = await req.formData();
const email = String(form.get("email") ?? "");
const message = String(form.get("message") ?? "");
const thanks = Response.redirect(new URL("/thanks/", req.url), 303);
if (String(form.get("_gotcha") ?? "").trim()) return thanks;
if ((message.match(LINK)?.length ?? 0) >= 3) return thanks;
const check = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body: new URLSearchParams({
secret: process.env.TURNSTILE_SECRET_KEY ?? "",
response: String(form.get("cf-turnstile-response") ?? ""),
remoteip: context.ip,
}),
});
const verdict = (await check.json()) as { success: boolean };
if (!verdict.success) return Response.redirect(new URL("/contact/?error=check", req.url), 303);
await fetch("https://api.resend.com/emails", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.RESEND_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
from: "Contact form <form@example.com>",
to: "you@example.com",
reply_to: email,
subject: "New message from the website",
text: message,
}),
});
return thanks;
};
export const config: Config = { path: "/api/contact" };context.ipis the visitor's IP, which siteverify can use asremoteip.- Set
TURNSTILE_SECRET_KEYandRESEND_API_KEYin the site's environment variables, never in the repository. - It drops caught posts instead of storing them, so a real message caught by mistake is gone. Log them at least.
- It has no rate limit, since a function has no memory between calls. The rate limiting guide adds one with Redis.
What Ikiform does for a static form
With the form posting straight to Ikiform, the server-side work happens there. The honeypot is _gotcha, content scoring and per-visitor rate limits run on every post, and Allowed domains marks posts from other sites as spam. Turn Spam check to Ikiform for Cloudflare Turnstile with no keys: the plain HTML post runs it on a check page, and the script above falls back to that page. A post judged as spam gets the same success answer as a real one, so a bot learns nothing. It is stored with the status spam, sends no notification email and no webhook, doesn't count toward your usage, and is deleted after 30 days. Every Monday, a workspace that blocked spam gets one summary email with the total, the busiest forms and the top reasons.
username.github.io. Netlify deploy previews and Vercel preview URLs aren't subdomains of your main site address; add the exact preview host while you test one.Check it works
- Turn JavaScript off and submit. The form still posts and you land on your thank-you page.
- Turn JavaScript on and submit. The form is replaced by the thank-you line.
- Post with the honeypot filled in,
_gotcha=bot. It still gets a success answer, but the submission is stored as spam and sends no email or webhook. - Set Spam check to Ikiform and submit with the script on. The page falls back to a normal post and shows the check page.
- Nothing arrived? Switch the dashboard's status filter to Spam. Spam gets the same success answer as a real submission.
Gotchas
403 origin_not_allowed, or every post lands in spam. The page's domain isn't in the form's Allowed domains. Add it, pluslocalhostfor local tests. Subdomains of a listed domain are allowed.- Minified HTML. Some build steps strip attributes they think are empty or unused. Check the built page still has
aria-hiddenandtabindexon the honeypot. - Cached thank-you pages. If the thank-you page is the same URL for spam and real posts, which it should be, never put anything on it that depends on the post being real.
- Keys in the repository. A secret committed to a public static site's repository is public. Rotate it if it ever was.
Questions
You can show the widget, but its token must be verified with a secret key on a server. Use a function on your host, a form backend that verifies your keys, or Ikiform's managed check, which needs no keys.
It stops the most common bots, but only if something on a server reads it. Pair it with a rate limit and content scoring, which also need a server or a form backend.
The form posts to a page on a host that only serves files. Point the form's action at a function or a form backend endpoint instead.
It should. Post with a plain HTML form first, and add a script that improves it and falls back to a normal post when anything goes wrong.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.