Stop contact form spam

Stop contact form spam6 min read

How to stop form spam without a captcha

Stop most form spam with no captcha at all: a honeypot, a signed render time, a content score and a rate limit, added together instead of used alone.

Preet SutharFounder, Ikiform

Combine four invisible checks and add up what they find: a honeypot, a signed timestamp that shows how long the form was open, a score for links and spam phrases, and a per-visitor rate limit. Treat a post as spam only when the total crosses a threshold, so no single soft signal blocks a real person. Keep what you catch, and add a captcha later only if the spam folder shows bots still getting through.

What's different without a captcha

A captcha gives you one strong yes or no. Without one, you have several weak signals instead, and each one alone is easy to dodge or easy to trip by accident. A fast post might be a bot, or someone who pasted a prepared message. A link might be spam, or a portfolio. The trick is to score: give each signal a weight, add them up, and only call it spam past a threshold. The one hard signal, a filled honeypot, gets a weight big enough to cross the line by itself.

How invisible signals add up to a verdict for three different postsA script botA pasted SEO pitchA real message with three linkshoneypot filled+5posted in 0.4 s+38: spamlink in the name+3"seo services"+25: spamthree links+2nothing else2: receivedHow invisible signals add up to a verdict for three different postsA script botA pasted SEO pitchA real message with three linkshoneypot filled+5posted in 0.4 s+38: spamlink in the name+3"seo services"+25: spamthree links+2nothing else2: received
  1. A script bot fills the honeypot, which adds 5, and posts in under a second, which adds 3. Its total of 8 is spam.
  2. A pasted SEO pitch has a link in the name field, 3, and a known spam phrase, 2. Its total of 5 reaches the threshold of 5.
  3. A real message with three links scores 2 and nothing else, so it is received.
The weights are Ikiform's, with a threshold of 5. Two soft signals together cross it; one never does.

A signed render time

People need several seconds to fill in a form. A time trap measures that gap. The weak version puts Date.now() in a hidden field, which any bot can rewrite. The strong version signs the render time on the server with a secret, so a bot can't claim the page was opened earlier than it was. This file runs anywhere with Web Crypto: Node 20 and later, Deno, Bun, Workers and every serverless runtime.

lib/render-token.ts
const encoder = new TextEncoder();

async function key(secret: string) {
  return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
    "sign",
    "verify",
  ]);
}

function toHex(bytes: ArrayBuffer) {
  return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}

function fromHex(hex: string) {
  return new Uint8Array((hex.match(/../g) ?? []).map((pair) => Number.parseInt(pair, 16)));
}

export async function signRender(secret: string, now = Date.now()) {
  const signature = await crypto.subtle.sign("HMAC", await key(secret), encoder.encode(`render.${now}`));
  return `${now}.${toHex(signature)}`;
}

export type RenderCheck = "ok" | "too_fast" | "invalid";

export async function checkRender(secret: string, token: string, minSeconds = 3, now = Date.now()): Promise<RenderCheck> {
  const [time = "", signature = ""] = token.split(".");
  if (!/^\d{13}$/.test(time) || !/^[0-9a-f]{64}$/.test(signature)) return "invalid";
  const valid = await crypto.subtle.verify("HMAC", await key(secret), fromHex(signature), encoder.encode(`render.${time}`));
  const age = now - Number(time);
  if (!valid || age < 0 || age > 86_400_000) return "invalid";
  return age < minSeconds * 1000 ? "too_fast" : "ok";
}

Sign when the page renders, so the page must be rendered per request, not cached as static HTML. In Astro:

src/pages/contact.astro
---
import { signRender } from "../lib/render-token";

export const prerender = false;

const rendered = await signRender(import.meta.env.RENDER_SECRET);
---

<form method="POST" action="/api/contact">
  <input type="hidden" name="rendered" value={rendered} />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <label for="name">Name</label>
  <input id="name" name="name" required />
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" required></textarea>
  <button>Send</button>
</form>
  • Three seconds is a safe minimum for a contact form. Someone pasting a prepared message can still take two.
  • Reject tokens older than a day. Otherwise a bot loads the page once and reuses the same token for weeks.
  • Treat a missing or forged token as a signal, not proof. A cached page or an old tab can produce one.

Score the post

Put the signals together in one function. Link and phrase checks catch what the honeypot and timing can't: messages typed or pasted by people. Keep the phrase list short and specific; broad words like free flag real customers.

lib/score.ts
const WEIGHTS = { honeypot: 5, too_fast: 3, invalid_render: 3, link_in_name: 3, many_links: 2, phrase: 2 };
const THRESHOLD = 5;
const LINK = /https?:\/\/|www\./gi;
const PHRASES = /\b(seo services|guest post|backlinks|first page of google|crypto investment)\b/i;

type Reason = keyof typeof WEIGHTS;

export function scoreContact(input: {
  name: string;
  message: string;
  trap: string;
  render: "ok" | "too_fast" | "invalid";
}) {
  const reasons: Reason[] = [];
  if (input.trap.trim()) reasons.push("honeypot");
  if (input.render === "too_fast") reasons.push("too_fast");
  if (input.render === "invalid") reasons.push("invalid_render");
  if (input.name.match(LINK)) reasons.push("link_in_name");
  if ((`${input.name} ${input.message}`.match(LINK)?.length ?? 0) >= 3) reasons.push("many_links");
  if (PHRASES.test(`${input.name} ${input.message}`)) reasons.push("phrase");
  const score = reasons.reduce((total, reason) => total + WEIGHTS[reason], 0);
  return { score, reasons, spam: score >= THRESHOLD };
}
src/pages/api/contact.ts
import type { APIRoute } from "astro";
import { checkRender } from "../../lib/render-token";
import { scoreContact } from "../../lib/score";

export const POST: APIRoute = async ({ request, redirect }) => {
  const form = await request.formData();
  const render = await checkRender(import.meta.env.RENDER_SECRET, String(form.get("rendered") ?? ""));
  const verdict = scoreContact({
    name: String(form.get("name") ?? ""),
    message: String(form.get("message") ?? ""),
    trap: String(form.get("_gotcha") ?? ""),
    render,
  });
  await saveMessage(Object.fromEntries(form), verdict.spam ? "spam" : "received", verdict.reasons);
  return redirect("/thanks", 303);
};

The handler stores everything with a status instead of dropping spam, and answers both with the same redirect. You review the spam later, and a bot never learns which signal caught it. Add a per-visitor rate limit in front of this route to stop floods before any of it runs.

Ikiform runs these without a captcha

Every Ikiform form runs the honeypot, timing, content scoring and rate limits with no setup, and its Spam check starts Off. The honeypot is _gotcha. Content scoring checks links, 22 known spam phrases, disposable email domains and identical repeats within 24 hours. The rate limit starts at 5 a minute per visitor. At 5 or more a post is spam.

SignalWeight
Honeypot filled in+5
Posted less than 2 seconds after the signed render+3
Render time forged or older than 24 hours+3
A link in a name or subject field+3
Identical fields within 24 hours+3
Three or more links+2
A known spam phrase+2
A disposable email domain+2

Ikiform's signed render time comes from the form's public schema. The shadcn/ui component sends it for you. A form you render yourself can fetch it with getSchema() from @ikiform/sdk and send it back as _ikiform_ts:

contact.js
import { getSchema, submit } from "@ikiform/sdk";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
const form = document.querySelector("form");
const schema = await getSchema(FORM_ID);
const rendered = schema.ok ? schema.schema.ts : null;

form?.addEventListener("submit", async (event) => {
  event.preventDefault();
  const data = new FormData(form);
  if (rendered) data.set("_ikiform_ts", rendered);
  const result = await submit(FORM_ID, data);
  form.replaceWith(result.ok ? "Thanks, we'll be in touch." : result.error.message);
});

Plain HTML forms don't send a render time and aren't penalised for it, so a missing one costs nothing. 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. The schema endpoint and spam docs have the details.

If a few bots still get through, turn Spam check to Ikiform. It adds Cloudflare Turnstile with no keys, and most visitors never see a thing. That is the point to add a captcha: when the spam folder says so, not before.

Check it works

  1. Load the form and submit it within a second. With your own handler, the post is stored as spam with too_fast.
  2. Edit the hidden rendered value in the browser's dev tools and submit. It scores as invalid_render.
  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. Send a message with three links and nothing else suspicious. It is received.
  5. Nothing arrived? Switch the dashboard's status filter to Spam. Spam gets the same success answer as a real submission.

Gotchas

  • Static pages. A page built once at deploy time has one render time for every visitor. Sign it per request, or fetch a fresh token with JavaScript when the form appears.
  • Password managers and paste. Some people fill a form in a second with autofill. Keep timing a soft signal so it never blocks on its own.
  • Phrase lists rot. Spam phrases change. Review what gets through every few weeks and add the specific phrase, never a common word.
  • Repeats are normal. People resend when they get no reply. Weigh an identical repeat, but don't block it outright.

Questions

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