# Spam protection

> Honeypot, signed timestamps, spam scoring, a human check, allowed domains and rate limits.

Every form gets layered protection with no setup: a honeypot, a signed render timestamp, spam scoring and rate limits. On top of that you can add a human check and restrict the form to your own domains.

| Layer | On by default | Outcome |
| --- | --- | --- |
| [Rate limits](https://ikiform.com/docs/rate-limits.md) | Yes, Standard preset | `429 rate_limited` |
| [Allowed domains](#allowed-domains) | No | Spam, or `403 origin_not_allowed` |
| [Human check](#human-check) | No | `403 challenge_required` or a check page |
| [Honeypot](#honeypot) | Yes | +5 to the score |
| [Spam scoring](#scoring) | Yes | Spam at 5 or more |

## How spam is handled

A post that is judged as spam is stored with the status `spam` and still gets a normal success response, so bots learn nothing. Spam sends no webhooks and no emails, doesn't count toward your monthly usage, and is deleted after 30 days. Review it in the dashboard or with the [REST API](https://ikiform.com/docs/api.md#spam); marking a submission as not spam sends its webhooks then.

## Honeypot

Add a hidden input that people never fill in. The default name is `_gotcha`; you can change it per form under **Honeypot field** in the form's settings. Hiding techniques are on [HTML forms](https://ikiform.com/docs/html.md#honeypot).

`Honeypot`:

```html
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
```

## Spam scoring

Each post gets a score from these signals. At 5 or more it is stored as spam.

| Signal | Reason in the dashboard | Score |
| --- | --- | --- |
| The honeypot is filled in | `honeypot` | +5 |
| Sent less than 2 seconds after the form rendered | `timestamp_fast` | +3 |
| The signed render timestamp is forged or older than 24 hours | `timestamp_invalid` | +3 |
| A link in a name or subject field | `url_in_name` | +3 |
| The same fields were sent to this form in the last 24 hours | `duplicate` | +3 |
| Three or more links in total | `many_urls` | +2 |
| A known spam phrase (22 built in) | `keyword` | +2 |
| An email address on a disposable email domain | `disposable_email` | +2 |

- The render timestamp only exists when the form is rendered by the SDK or the [shadcn/ui component](https://ikiform.com/docs/shadcn.md), which send a signed `_ikiform_ts`. Plain HTML posts without it are not penalised.
- A name field is any field whose name contains `name`, `firstname`, `lastname`, `fullname`, `surname` or `subject`, but not words like `company`, `website` or `domain`.
- Two reasons force spam regardless of score: `origin_mismatch` from [allowed domains](#allowed-domains) and `captcha_unavailable` when Turnstile can't be reached.

## Human check

Each form has a **Spam check** setting with three options. It is off by default, so existing `fetch` and curl integrations keep working when you create a form.

| Option | What it does |
| --- | --- |
| Off | No challenge. Honeypot, timestamp and scoring still run. |
| Ikiform (recommended) | Cloudflare Turnstile run by Ikiform on ikiform.com. No Cloudflare account or keys needed. |
| Your own Turnstile keys | Your Turnstile widget and secret key. The token's hostname is checked against your allowed domains. |

### How the Ikiform check runs

How the token is obtained depends on how you submit:

- **HTML form post:** the post without a token gets a short "Checking you're human" page on ikiform.com. It runs Turnstile and posts the original fields again with the token, then the browser follows your redirect as usual.
- **JavaScript:** call `challenge(formId)` from the SDK. It mounts a small ikiform.com frame that stays invisible unless Turnstile needs a click, and resolves with a token. Pass it to `submit` as `turnstileToken`. A `fetch` post without a token gets `403 challenge_required`.
- **shadcn/ui component:** handled for you.
- **Server with a secret key:** the check is skipped, since a server can't solve it.

`form.js`:

```js
import { challenge, submit } from "@ikiform/sdk";

const form = document.querySelector("form");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const turnstileToken = await challenge("frm_xxxxxxxxxxxxxxxx");
  const result = await submit("frm_xxxxxxxxxxxxxxxx", new FormData(form), { turnstileToken });
  if (result.ok) form.replaceWith("Thanks, we got it.");
});
```

Tokens are single use and live 300 seconds, so get one per submit. A JSON post without a token gets `403 challenge_required` with a `challenge_url`; a token that fails verification gets `403 captcha_failed`.

`403 Forbidden`:

```json
{
  "ok": false,
  "error": {
    "code": "challenge_required",
    "message": "Complete the human check, then send the form again.",
    "challenge_url": "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx/challenge"
  },
  "request_id": "req_01k7v2c9t3e8m5q4x6z0a1b2c3"
}
```

> If Turnstile itself can't be reached, the post is accepted and stored as spam with the reason `captcha_unavailable`, so you can restore it rather than lose it.

### Your own Turnstile keys

Paste your site key and secret key into the form's settings. The secret is encrypted at rest. Render the widget in your form so it adds `cf-turnstile-response` to the post, or pass the token to `submit` as `turnstileToken`. Posts without a valid token get `403 captcha_failed`, including posts with a secret API key.

## Allowed domains

List the domains your form lives on under **Allowed domains**. A post must then come from one of them or a subdomain, judged by the `Origin` header, or the `Referer` when there is no origin. Leave the list empty to accept posts from any site.

| Allowed domains | Post from | Matches |
| --- | --- | --- |
| `example.com` | `https://example.com` | Yes |
| `example.com` | `https://www.example.com` | Yes, subdomain |
| `example.com` | `http://localhost:3000` | No, add `localhost` while developing |
| `example.com` | `https://example.co` | No |
| `app.example.com` | `https://example.com` | No, a parent domain doesn't match |

**When a post comes from another site** decides what happens to a mismatch: **Mark as spam** (the default) or **Reject** with `403 origin_not_allowed`. Posts with a secret API key skip this check. Allowed domains also decide which `_redirect` targets are followed and where the Ikiform check frame may be embedded.

## Rate limits

Each form has a **Rate limit** preset, counted exactly per visitor IP and form. Standard allows 5 posts a minute, Relaxed 20 a minute and Strict 2 every 10 minutes. The full rules are on [Rate limits](https://ikiform.com/docs/rate-limits.md).
