# 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.

Published 2026-10-25 · https://ikiform.com/guides/spam-protection/without-captcha

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.

Diagram: How invisible signals add up to a verdict for three different posts

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`:

```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`:

```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`:

```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`:

```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](https://ikiform.com/guides/spam-protection/rate-limiting.md) 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.

| Signal | Weight |
| --- | --- |
| 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](https://ikiform.com/docs/shadcn.md) 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`:

```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](https://ikiform.com/docs/shadcn.md#schema-endpoint) and [spam docs](https://ikiform.com/docs/spam.md#scoring) 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

### Can I stop form spam without a captcha?

Most of it. A honeypot, a signed render time, a content score and a rate limit stop the bulk of what a small site gets, at no cost to real visitors. Bots that run a full browser and wait may still get through, which is when an invisible check such as Turnstile helps.

### How long should a time trap wait?

Around three seconds for a contact form. Shorter misses fast bots; longer starts catching people who paste a prepared message or use autofill. Use it as one signal among several, not a hard block.

### Why sign the timestamp?

A plain timestamp in a hidden field can be rewritten by any bot to look older. Signing it on the server with a secret means only your server can produce a valid render time.

### Does Ikiform need a captcha to filter spam?

No. The honeypot, signed timing, content scoring and rate limits run on every form with the spam check off. The managed Turnstile check is an extra you turn on per form if spam still gets through.

## More guides

- [Stop contact form spam](https://ikiform.com/guides/spam-protection.md): 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.
- [Honeypot field](https://ikiform.com/guides/spam-protection/honeypot.md): Add a honeypot to any form that catches bots without tripping screen readers, keyboards or autofill, with the server check for every stack.
- [Rate limiting](https://ikiform.com/guides/spam-protection/rate-limiting.md): Rate limit form submissions per visitor with a shared store, answer 429 with Retry-After, and avoid locking out offices that share one IP.
- [reCAPTCHA alternatives](https://ikiform.com/guides/spam-protection/recaptcha-alternatives.md): Compare reCAPTCHA alternatives for forms, from Turnstile and hCaptcha to proof-of-work and no captcha at all, then switch with a small, safe change.
- [Cloudflare Turnstile](https://ikiform.com/guides/spam-protection/cloudflare-turnstile.md): Add Cloudflare Turnstile to a plain HTML form: the widget, server-side siteverify, test keys, and a way to run it with no keys or server of your own.
- [Static sites](https://ikiform.com/guides/spam-protection/static-site.md): 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.
