Stop contact form spam

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.

Preet SutharFounder, Ikiform

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.

Three ways a static site form can be wired, and where spam checks run in eachThe page aloneWith a host functionWith a form backendstatic pageform posts to itselfhostno code runs405, or loststatic pagefunctionsecret, honeypot, tokencheckyour email or storestatic pageform backendhoneypot, score, limit,Turnstileinbox and webhooksThree ways a static site form can be wired, and where spam checks run in eachThe page aloneWith a host functionWith a form backendstatic pageform posts to itselfhostno code runs405, or loststatic pagefunctionsecret, honeypot, token checkyour email or storestatic pageform backendhoneypot, score, limit, Turnstileinbox and webhooks
  1. A static page that posts to itself reaches a host that runs no code, so the post fails with 405 or goes nowhere.
  2. 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.
  3. 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.
In both working rows, the checks run after the post leaves the browser. That is the only place a bot can't skip them.

Your options

OptionSpam checksTrade-offs
Netlify FormsAkismet on every post, plus a netlify-honeypot field and reCAPTCHA v2 if you add themOnly on Netlify. Moving host means rebuilding the form. See Netlify Forms alternatives
A function on your hostWhatever you write: honeypot, token checks, a rate limit with a storeYou write and maintain it, and need an email or storage service behind it
A form backend, such as IkiformHoneypot, scoring, rate limits, allowed domains and a human check, with no codeAnother 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>
assets/contact.css
<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.
  • _redirect sends people to your thank-you page. Ikiform follows it only for an https: 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.

contact-form.html
<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 the submit event again, so the fallback can't loop.
  • A 429 shows how long to wait from Retry-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.

netlify/functions/contact.mts
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.ip is the visitor's IP, which siteverify can use as remoteip.
  • Set TURNSTILE_SECRET_KEY and RESEND_API_KEY in 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.

List every host the form is published on under Allowed domains: your domain, plus the host's own address while you test, such as 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

  1. Turn JavaScript off and submit. The form still posts and you land on your thank-you page.
  2. Turn JavaScript on and submit. The form is replaced by the thank-you line.
  3. 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.
  4. Set Spam check to Ikiform and submit with the script on. The page falls back to a normal post and shows the check page.
  5. 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, plus localhost for 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-hidden and tabindex on 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

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.