Guides

Guides16 min read

How to stop contact form spam

Stop contact form spam with layers that cost real people nothing: a honeypot, timing, content scoring, rate limits and a human check only where needed.

Preet SutharFounder, Ikiform

Stack cheap, invisible checks before you add any friction: a hidden honeypot field, a minimum time to fill the form, a score for links and spam phrases, and a rate limit per visitor. Add a human check such as Cloudflare Turnstile only when spam still gets through, and verify its token on a server. Store what you catch instead of throwing it away, so you can rescue the odd real message.

A contact form on a public page starts getting junk within days of being indexed. Most of it isn't aimed at you. Scripts crawl the web for any <form>, fill every input and post it, hoping a link lands somewhere. A smaller share comes from real browsers driven by software, and some from people paid to paste the same pitch into thousands of forms. Each kind is stopped by a different check, which is why one trick never fixes it.

This guide explains each defence, what it catches and what it costs real visitors, with code you can run on any stack. Then it shows how Ikiform, a form backend, runs the same layers for you.

Layers, cheapest first

Order the checks by what they cost. Counting requests is nearly free, so it runs before anything reads the body. Checks that call another service, like a captcha, run once the cheap ones pass. Scoring the content comes last, because it needs the parsed fields. Ikiform runs its checks in this order on every post.

The order spam checks run in, from a post arriving to stored or spamRefused outrightScored, then stored either waya postarrivesfrom a page, ascript or aserverrate limittoo many fromone visitor:429alloweddomainsposted fromanother site?humancheckTurnstiletoken, whenturned onhoneypotfilled in: +5timing andcontenttoo fast,links,phrases,repeatsscore 5or more?receivedemail andwebhooks gooutspamsame successanswer,nothing sentcountorigintokennoyesThe order spam checks run in, from a post arriving to stored or spamRefused outrightScored, then stored either waya post arrivesfrom a page, ascript or a serverrate limittoo many from onevisitor: 429allowed domainsposted from anothersite?human checkTurnstile token,when turned onhoneypotfilled in: +5timing andcontenttoo fast, links,phrases, repeatsscore 5 ormore?receivedemail and webhooksgo outspamsamesuccessanswer,nothingsentcountorigintokennoyes
  1. A post arrives at the form endpoint.
  2. The rate limit counts posts per visitor first. A flood is refused with 429 before the body is read.
  3. Allowed domains compare the page the post came from with the domains you listed.
  4. If the human check is on, the post must carry a valid Turnstile token.
  5. The honeypot adds 5 to the score when it is filled in.
  6. Timing, links, known spam phrases, disposable addresses and repeats add smaller amounts.
  7. At 5 or more the post is stored as spam. Below it, the post is received and your email and webhooks go out.
The top row refuses posts. The bottom row never refuses: it scores, so a false positive is a click to restore, not a lost message.

The defences compared

Every defence trades something. The table is honest about what each one misses and what it asks of the people you want to hear from.

DefenceStopsMissesCost to real visitors
HoneypotScripts that fill every inputBots that read CSS or skip hidden fields, and humansNone, if it is hidden from screen readers and autofill
Time trapPosts sent faster than anyone can typeSlow bots, and bots that waitNone, as long as the minimum is a few seconds
Content scoring, such as link counts, spam phrases or AkismetSEO pitches, link drops, casino and pill spamClean-looking spam, and it can flag a real message with linksNone, if you review what it catches
Rate limitingFloods and scripts in a loopSlow, spread-out spamOffices and events behind one IP can hit a tight limit
Allowed domainsPosts sent straight to your endpoint from elsewhereScripts that fake the Origin headerNone
Cloudflare TurnstileMost automated browsersPaid human spammersUsually invisible. Sometimes a click, and it needs a script
reCAPTCHA or hCaptchaMost automated browsersPaid human spammers and solving servicesImage puzzles for some visitors, plus a third-party script

Start with the first five. They are invisible and catch the bulk of what a small site gets. Add a human check when the dashboard tells you spam still gets through, not before: it is the only layer with a cost every visitor can feel. The page on stopping spam without a captcha shows how far the invisible layers go on their own.

Read the spam before you fight it

Look at ten spam messages before you choose a fix. What they have in common tells you who sends them, and that points to the layer that stops them.

From what spam looks like to the layer that stops itrandom text in everyfielda script botparses the HTML, fillsall inputshoneypotdozens a minute, onesourcea script in a looprate limittidy posts within asecond of loadinga headless browsertime trap, humancheckpolite pitches withlinksa person, paid performcontent score,reviewWhat you seeWho sends itWhat stops itFrom what spam looks like to the layer that stops itrandom text in every fielda script botparses the HTML, fills allinputshoneypotdozens a minute, onesourcea script in a looprate limittidy posts within a secondof loadinga headless browsertime trap, human checkpolite pitches with linksa person, paid per formcontent score, review
  1. Random text in every field comes from a script that fills all inputs. A honeypot catches it.
  2. Dozens of posts a minute from one source come from a script in a loop. A rate limit stops it.
  3. Tidy posts sent within a second of the page loading come from a headless browser. A time trap or a human check stops it.
  4. Polite pitches with links come from people paid per form. Only content scoring and your own review catch them.
No captcha stops the last row. A person can solve it, so content scoring and a weekly look at the spam folder are the only defence.

A contact form with the layers wired in

Here is one contact form, built for each stack. Every version has the off-screen honeypot and a human check. The browser-only versions use Ikiform's managed Turnstile check, which needs no keys. The server versions show the do-it-yourself route: your server verifies a Turnstile token with your own Cloudflare keys, then forwards the fields. Pick your stack below; the choice carries over to every code sample in these guides.

The server tabs share two small helpers. verifyTurnstile asks Cloudflare whether a token is real, and sendToIkiform forwards the fields with your secret key and the visitor's Origin header. A server-side fetch sends no Origin of its own, and Ikiform reads it to recognise a free localhost test. Both fail closed: if Cloudflare can't be reached, the visitor sees an error rather than a silent pass.

lib/spam.ts
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

export interface Contact {
  name: string;
  email: string;
  message: string;
  _gotcha: string;
}

export function readContact(data: FormData): Contact {
  return {
    name: String(data.get("name") ?? "").trim(),
    email: String(data.get("email") ?? "").trim(),
    message: String(data.get("message") ?? "").trim(),
    _gotcha: String(data.get("_gotcha") ?? ""),
  };
}

export async function verifyTurnstile(input: { secret: string; token: string; ip?: string | null }) {
  if (!input.token || input.token.length > 2048) return false;
  const body = new URLSearchParams({ secret: input.secret, response: input.token });
  if (input.ip) body.set("remoteip", input.ip);
  try {
    const response = await fetch(SITEVERIFY, { method: "POST", body });
    const result = (await response.json()) as { success?: boolean };
    return result.success === true;
  } catch {
    return false;
  }
}

export async function sendToIkiform(secretKey: string, contact: Contact, origin?: string | null) {
  try {
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${secretKey}`,
        ...(origin ? { Origin: origin } : {}),
      },
      body: JSON.stringify(contact),
    });
    const result = await response.json().catch(() => null);
    if (result?.ok) return { ok: true as const };
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    const first = Object.values(fields)[0]?.[0];
    return {
      ok: false as const,
      status: response.status,
      message: String(first ?? result?.error?.message ?? "Couldn't send your message. Try again."),
    };
  } catch {
    return { ok: false as const, status: 502, message: "Couldn't reach the form service. Try again." };
  }
}

The plain form needs no script. Set the Ikiform form's Spam check to Ikiform, and a post without a token gets a short check page on ikiform.com that runs Turnstile and sends the fields on. People who pass it invisibly never notice.

contact.html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required maxlength="120" />

  <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 minlength="10" maxlength="5000"></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>

  <button>Send message</button>
</form>

<style>
.hp {
  position: absolute;
  left: -10000px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
</style>

The honeypot sits off-screen rather than behind display: none, because some bots skip fields that are hidden that way. The wrapper's aria-hidden and the input's tabindex keep screen readers and keyboards away from it.

  • Every version uses the .hp class from the HTML tab's style block. Put it in your global CSS.
  • The honeypot is named _gotcha, Ikiform's default. The server versions forward it untouched, so a filled one still lands in your spam folder with the reason honeypot.
  • Turnstile adds a hidden cf-turnstile-response input to the form. A token is valid for 300 seconds and works once, so each attempt needs a new one.
  • Test keys from Cloudflare let you run all of this locally. The Turnstile guide lists them.
The server-side versions post with a secret key, so Ikiform skips Allowed domains and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom rate limit for a form you post through a server, and keep IKIFORM_SECRET_KEY in server-only environment variables.

Try the field definition below. It runs Ikiform's validator in your browser, the same rules the server applies before any spam score is counted.

Preview

Runs Ikiform's validator in your browser. Nothing is sent.

The same fields as the contact form template. Paste the definition on the form's Fields tab and Ikiform refuses posts that don't match it with 422 validation_failed.

The honeypot

A honeypot is an input that people never see and bots fill in. If it has a value, the post came from software. It costs visitors nothing and catches the oldest, most common bots, the ones that parse the HTML and fill every input they find.

Hiding it well matters more than the trick itself. Three things go wrong with careless honeypots:

  • Screen readers announce it. A blind visitor hears an extra field, fills it in, and gets marked as spam. Wrap it in aria-hidden="true" and give it a label that says to leave it empty.
  • Keyboards land on it. Tabbing through the form stops on an invisible input. tabindex="-1" takes it out of the tab order.
  • Autofill fills it. Browsers and password managers fill fields by name. Call the field email2 or phone and some will fill it for real people. Use a name no autofill recognises, such as _gotcha, and add autocomplete="off".

The honeypot guide covers hiding techniques, how bots spot them, and the server-side check for each stack. With Ikiform, a filled honeypot adds 5 to the score, which reaches the threshold of 5 on its own. You can rename the field per form under Honeypot field.

Timing

People take seconds to fill in a contact form. Many bots take milliseconds. A time trap records when the form was rendered and checks the gap when it is posted. The naive version puts a timestamp in a hidden field:

Time trap, the naive version
const started = Number(formData.get("started"));
const seconds = (Date.now() - started) / 1000;
const tooFast = !Number.isFinite(seconds) || seconds < 3;

A bot can send any number it likes, so a plain timestamp only stops bots that don't bother. Sign it on the server with a secret, and the bot can't fake a slower render. The page on stopping spam without a captcha builds the signed version.

Ikiform signs render times for you when the form comes from the SDK or the shadcn/ui component: they send _ikiform_ts, and a post less than 2 seconds after render adds 3 to the score. A forged or day-old timestamp adds 3. Plain HTML forms don't send one and aren't penalised.

Scoring the content

Some spam is typed or pasted by people, so it passes every bot check. What gives it away is the content: links, sales phrases, throwaway addresses and the same text twice. Score each signal and treat a high total as spam, rather than blocking on any single one. One link in a message is normal; a link in the name field never is.

SignalIkiform adds
The honeypot is filled in+5
A link in a name or subject field+3
The same fields sent to this form in the last 24 hours+3
Sent less than 2 seconds after the signed render time+3
Three or more links across all fields+2
One of 22 known spam phrases, such as "seo services"+2
An address on a disposable email domain+2

At 5 or more the post is spam. No single soft signal gets there: a real message with three links scores 2. Two together often do, such as a link in the name and a spam phrase. If you score content yourself, Akismet is the long-standing hosted option; many teams start with a few regular expressions like the ones above.

A human check

A human check asks the browser to prove it is driven by a person. Modern ones, like Cloudflare Turnstile, mostly run invisibly and only show a checkbox when unsure. The widget gives the browser a token; your server must send that token to the provider to verify it. A token that is never verified on a server stops nothing, because a bot can skip the widget and post without it.

Ikiform has three settings under Spam check on each form:

SettingWhat happensCode changes
Off (the default)No challenge. The honeypot, timing and scoring still run.None
IkiformTurnstile run by Ikiform on ikiform.com, with no Cloudflare account or keysNone for HTML posts. fetch posts call challenge() from @ikiform/sdk first
Your own Turnstile keysYour widget and secret key. Ikiform verifies the token and checks its hostname against your allowed domainsRender the widget in your form so it sends cf-turnstile-response

A fetch post without a token gets 403 challenge_required. If Cloudflare can't be reached, Ikiform keeps the post and stores it as spam with the reason captcha_unavailable, so you can restore it. The spam docs show both flows. If you're weighing Turnstile against Google's reCAPTCHA or hCaptcha, see reCAPTCHA alternatives.

Rate limits and allowed domains

A rate limit caps how often one visitor can post. It doesn't tell spam from mail; it stops volume. Count per IP and form, allow a small burst, and answer 429 with a Retry-After header. The rate limiting guide builds one for each stack and explains why an in-memory counter fails on serverless hosts.

Ikiform counts every post exactly, before reading the body. Each form picks a preset: Standard, 5 a minute per visitor, Relaxed, 20 a minute per visitor, or Strict, 2 every 10 minutes per visitor. Standard also allows at most 3 every 10 seconds, and the whole form takes 300 a minute. A contact form suits Strict, since nobody honest writes twice in ten minutes. The numbers are on Rate limits.

Allowed domains lists the sites your form lives on. A post from any other page is marked as spam, or refused with 403 origin_not_allowed if you choose Reject. It stops people from pointing their own forms at your endpoint. A determined script can fake the header it checks, so treat it as one layer, not a lock.

What happens to spam

Never tell a bot it was caught. An error teaches it what to change; a normal success page teaches it nothing. And never delete spam on arrival: every filter catches a real message now and then, and a deleted one is gone.

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.

Open the dashboard's status filter and pick Spam to see each one with its score and reasons. Not spam moves it back, and its email and webhooks go out then. Turn the Monday email off under Weekly spam summary in workspace settings. Spam is kept for 30 days, so check at least once a month.

If one real message keeps getting caught, read its reasons. A link in a field called business_name scores as a link in a name; rename the field to business and it no longer counts as a name field.

Pick your situation

Each of these pages goes deeper on one defence or one kind of site, with code for that case.

Check it works

Run these against a test form before you rely on it. Ikiform has no free plan. Testing is free: a workspace without a plan accepts 50 test submissions a month from localhost. To go live, pick Pro from $6/mo for 500 submissions, or pay once: 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire. See pricing.

  1. Under the form's Allowed domains, add your site's domain, plus localhost while you develop. Leave it empty to accept posts from anywhere.
  2. Fill in the form on your page and submit it. You land on your thank-you page, or Ikiform's hosted one.
  3. Open Submissions in the dashboard. The new submission is there with every field you sent.
  4. 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.
  5. Send a message with a link in the name field and the phrase seo services in the message. It is stored as spam with the reasons url_in_name and keyword.
  6. Turn Spam check to Ikiform and submit the plain HTML form. You see the check page for a moment, then your thank-you page.
  7. Submit the form a few times in a row. Past the limit, the form answers 429 and the visitor sees Ikiform's slow-down page.
  8. Nothing arrived? Switch the dashboard's status filter to Spam. Spam gets the same success answer as a real submission.

Troubleshooting

ProblemWhat to do
The submission is in the dashboard but no email or webhook arrivedIt was stored as spam. Spam sends nothing. Open it under the Spam filter to see the reasons, and mark it as not spam to send its email and webhooks.
403 challenge_requiredThe form's spam check is set to Ikiform and a fetch post had no token. Get one with challenge(formId) from @ikiform/sdk before each submit, or turn the check off.
403 origin_not_allowed, or every post lands in spamThe 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.
429 rate_limitedOne visitor sent more than the form's rate limit allows. Standard allows 5 a minute per visitor. Wait for Retry-After, or choose Relaxed or Custom for forms that post from one shared IP.
403 captcha_failedThe Turnstile token was missing, expired or used twice. Tokens live 300 seconds and work once, so render a fresh widget, or call challenge() again, after every attempt.
Spam still arrives as receivedOpen one and read what it has in common with the rest. Scripted spam calls for Spam check set to Ikiform; typed pitches call for a stricter rate limit and a weekly review.
Real visitors report they can't sendCheck that the honeypot is hidden from screen readers and named so autofill skips it, and that a strict rate limit isn't catching people behind one office IP.

Questions

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